studiograph 1.3.47 → 1.3.48-beta.1

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 (1067) hide show
  1. package/README.md +26 -16
  2. package/dist/agent/agent-pool.d.ts +117 -0
  3. package/dist/agent/agent-pool.js +287 -0
  4. package/dist/agent/agent-pool.js.map +1 -0
  5. package/dist/agent/followups.d.ts +34 -0
  6. package/dist/agent/followups.js +52 -0
  7. package/dist/agent/followups.js.map +1 -0
  8. package/dist/agent/orchestrator.d.ts +113 -22
  9. package/dist/agent/orchestrator.js +573 -181
  10. package/dist/agent/orchestrator.js.map +1 -1
  11. package/dist/agent/prompts/entity-types-section.d.ts +36 -0
  12. package/dist/agent/prompts/entity-types-section.js +90 -0
  13. package/dist/agent/prompts/entity-types-section.js.map +1 -0
  14. package/dist/agent/prompts/system.md +148 -69
  15. package/dist/agent/skill-loader.d.ts +30 -17
  16. package/dist/agent/skill-loader.js +63 -30
  17. package/dist/agent/skill-loader.js.map +1 -1
  18. package/dist/agent/skills/entity-schema.md +77 -433
  19. package/dist/agent/skills/interview/entity-templates.md +193 -0
  20. package/dist/agent/skills/interview/question-domains.md +391 -0
  21. package/dist/agent/skills/interview/skill.md +204 -0
  22. package/dist/agent/skills/schema-rules.md +54 -0
  23. package/dist/agent/tools/capture-tools.d.ts +31 -0
  24. package/dist/agent/tools/capture-tools.js +40 -0
  25. package/dist/agent/tools/capture-tools.js.map +1 -0
  26. package/dist/agent/tools/folder-tools.d.ts +47 -0
  27. package/dist/agent/tools/folder-tools.js +249 -0
  28. package/dist/agent/tools/folder-tools.js.map +1 -0
  29. package/dist/agent/tools/fs-tools.d.ts +7 -6
  30. package/dist/agent/tools/fs-tools.js +5 -4
  31. package/dist/agent/tools/fs-tools.js.map +1 -1
  32. package/dist/agent/tools/graph-tools.d.ts +21 -58
  33. package/dist/agent/tools/graph-tools.js +635 -332
  34. package/dist/agent/tools/graph-tools.js.map +1 -1
  35. package/dist/agent/tools/history-tools.d.ts +95 -0
  36. package/dist/agent/tools/history-tools.js +461 -0
  37. package/dist/agent/tools/history-tools.js.map +1 -0
  38. package/dist/agent/tools/load-skill.d.ts +6 -5
  39. package/dist/agent/tools/load-skill.js +3 -3
  40. package/dist/agent/tools/load-skill.js.map +1 -1
  41. package/dist/agent/tools/ops-tools.d.ts +5 -4
  42. package/dist/agent/tools/ops-tools.js +130 -236
  43. package/dist/agent/tools/ops-tools.js.map +1 -1
  44. package/dist/agent/tools/permission-tools.d.ts +87 -17
  45. package/dist/agent/tools/permission-tools.js +233 -38
  46. package/dist/agent/tools/permission-tools.js.map +1 -1
  47. package/dist/agent/tools/tool-loader.js +5 -4
  48. package/dist/agent/tools/tool-loader.js.map +1 -1
  49. package/dist/agent/tools/web-tools.d.ts +18 -0
  50. package/dist/agent/tools/web-tools.js +283 -0
  51. package/dist/agent/tools/web-tools.js.map +1 -0
  52. package/dist/cli/commands/about.d.ts +1 -0
  53. package/dist/cli/commands/about.js +55 -3
  54. package/dist/cli/commands/about.js.map +1 -1
  55. package/dist/cli/commands/admin/audit-workspace.d.ts +23 -0
  56. package/dist/cli/commands/admin/audit-workspace.js +133 -0
  57. package/dist/cli/commands/admin/audit-workspace.js.map +1 -0
  58. package/dist/cli/commands/admin/audit.d.ts +21 -0
  59. package/dist/cli/commands/admin/audit.js +174 -0
  60. package/dist/cli/commands/admin/audit.js.map +1 -0
  61. package/dist/cli/commands/admin/backup.d.ts +54 -0
  62. package/dist/cli/commands/admin/backup.js +207 -0
  63. package/dist/cli/commands/admin/backup.js.map +1 -0
  64. package/dist/cli/commands/admin/folder.d.ts +11 -0
  65. package/dist/cli/commands/admin/folder.js +186 -0
  66. package/dist/cli/commands/admin/folder.js.map +1 -0
  67. package/dist/cli/commands/admin/index.d.ts +16 -0
  68. package/dist/cli/commands/admin/index.js +46 -0
  69. package/dist/cli/commands/admin/index.js.map +1 -0
  70. package/dist/cli/commands/admin/migrate-assets.d.ts +53 -0
  71. package/dist/cli/commands/admin/migrate-assets.js +184 -0
  72. package/dist/cli/commands/admin/migrate-assets.js.map +1 -0
  73. package/dist/cli/commands/admin/migrate.d.ts +56 -0
  74. package/dist/cli/commands/admin/migrate.js +263 -0
  75. package/dist/cli/commands/admin/migrate.js.map +1 -0
  76. package/dist/cli/commands/admin/restore.d.ts +24 -0
  77. package/dist/cli/commands/admin/restore.js +228 -0
  78. package/dist/cli/commands/admin/restore.js.map +1 -0
  79. package/dist/cli/commands/admin/secrets.d.ts +23 -0
  80. package/dist/cli/commands/admin/secrets.js +256 -0
  81. package/dist/cli/commands/admin/secrets.js.map +1 -0
  82. package/dist/cli/commands/admin/settings.d.ts +28 -0
  83. package/dist/cli/commands/admin/settings.js +284 -0
  84. package/dist/cli/commands/admin/settings.js.map +1 -0
  85. package/dist/cli/commands/admin/token.d.ts +42 -0
  86. package/dist/cli/commands/admin/token.js +126 -0
  87. package/dist/cli/commands/admin/token.js.map +1 -0
  88. package/dist/cli/commands/admin/transfer-ownership.d.ts +15 -0
  89. package/dist/cli/commands/admin/transfer-ownership.js +106 -0
  90. package/dist/cli/commands/admin/transfer-ownership.js.map +1 -0
  91. package/dist/cli/commands/admin/user.d.ts +11 -0
  92. package/dist/cli/commands/admin/user.js +187 -0
  93. package/dist/cli/commands/admin/user.js.map +1 -0
  94. package/dist/cli/commands/clone.d.ts +25 -3
  95. package/dist/cli/commands/clone.js +142 -36
  96. package/dist/cli/commands/clone.js.map +1 -1
  97. package/dist/cli/commands/commit.d.ts +1 -1
  98. package/dist/cli/commands/commit.js +24 -11
  99. package/dist/cli/commands/commit.js.map +1 -1
  100. package/dist/cli/commands/config.d.ts +23 -107
  101. package/dist/cli/commands/config.js +52 -260
  102. package/dist/cli/commands/config.js.map +1 -1
  103. package/dist/cli/commands/connector.d.ts +10 -13
  104. package/dist/cli/commands/connector.js +16 -58
  105. package/dist/cli/commands/connector.js.map +1 -1
  106. package/dist/cli/commands/deploy.js +215 -68
  107. package/dist/cli/commands/deploy.js.map +1 -1
  108. package/dist/cli/commands/index.js +2 -2
  109. package/dist/cli/commands/index.js.map +1 -1
  110. package/dist/cli/commands/init.js +10 -30
  111. package/dist/cli/commands/init.js.map +1 -1
  112. package/dist/cli/commands/mcp.js +12 -4
  113. package/dist/cli/commands/mcp.js.map +1 -1
  114. package/dist/cli/commands/orphans.js +14 -30
  115. package/dist/cli/commands/orphans.js.map +1 -1
  116. package/dist/cli/commands/r2.d.ts +3 -0
  117. package/dist/cli/commands/r2.js +223 -204
  118. package/dist/cli/commands/r2.js.map +1 -1
  119. package/dist/cli/commands/redeploy.js +65 -12
  120. package/dist/cli/commands/redeploy.js.map +1 -1
  121. package/dist/cli/commands/reset.d.ts +22 -6
  122. package/dist/cli/commands/reset.js +142 -63
  123. package/dist/cli/commands/reset.js.map +1 -1
  124. package/dist/cli/commands/serve.js +209 -24
  125. package/dist/cli/commands/serve.js.map +1 -1
  126. package/dist/cli/commands/sync.d.ts +1 -1
  127. package/dist/cli/commands/sync.js +372 -202
  128. package/dist/cli/commands/sync.js.map +1 -1
  129. package/dist/cli/crypto-bundle.d.ts +39 -0
  130. package/dist/cli/crypto-bundle.js +94 -0
  131. package/dist/cli/crypto-bundle.js.map +1 -0
  132. package/dist/cli/index.js +24 -35
  133. package/dist/cli/index.js.map +1 -1
  134. package/dist/cli/railway-pin.d.ts +68 -0
  135. package/dist/cli/railway-pin.js +96 -0
  136. package/dist/cli/railway-pin.js.map +1 -0
  137. package/dist/cli/railway-provisioning.d.ts +53 -0
  138. package/dist/cli/railway-provisioning.js +85 -0
  139. package/dist/cli/railway-provisioning.js.map +1 -0
  140. package/dist/cli/setup-wizard.d.ts +33 -52
  141. package/dist/cli/setup-wizard.js +43 -68
  142. package/dist/cli/setup-wizard.js.map +1 -1
  143. package/dist/core/accent-palette.d.ts +22 -0
  144. package/dist/core/accent-palette.js +47 -0
  145. package/dist/core/accent-palette.js.map +1 -0
  146. package/dist/core/cell-anchor.d.ts +29 -0
  147. package/dist/core/cell-anchor.js +53 -0
  148. package/dist/core/cell-anchor.js.map +1 -0
  149. package/dist/core/comment-anchor.d.ts +41 -0
  150. package/dist/core/comment-anchor.js +147 -0
  151. package/dist/core/comment-anchor.js.map +1 -0
  152. package/dist/core/commit-messages.d.ts +57 -0
  153. package/dist/core/commit-messages.js +85 -0
  154. package/dist/core/commit-messages.js.map +1 -0
  155. package/dist/core/embeds.d.ts +56 -0
  156. package/dist/core/embeds.js +103 -0
  157. package/dist/core/embeds.js.map +1 -0
  158. package/dist/core/entity-body-templates.d.ts +21 -0
  159. package/dist/core/entity-body-templates.js +53 -0
  160. package/dist/core/entity-body-templates.js.map +1 -0
  161. package/dist/core/entity-types-catalog.d.ts +65 -0
  162. package/dist/core/entity-types-catalog.js +140 -0
  163. package/dist/core/entity-types-catalog.js.map +1 -0
  164. package/dist/core/folder-paths.d.ts +41 -0
  165. package/dist/core/folder-paths.js +95 -0
  166. package/dist/core/folder-paths.js.map +1 -0
  167. package/dist/core/folder-sidecar.d.ts +36 -0
  168. package/dist/core/folder-sidecar.js +91 -0
  169. package/dist/core/folder-sidecar.js.map +1 -0
  170. package/dist/core/graph.d.ts +675 -14
  171. package/dist/core/graph.js +2118 -314
  172. package/dist/core/graph.js.map +1 -1
  173. package/dist/core/history-diff.d.ts +35 -0
  174. package/dist/core/history-diff.js +114 -0
  175. package/dist/core/history-diff.js.map +1 -0
  176. package/dist/core/schema-registry.d.ts +59 -0
  177. package/dist/core/schema-registry.js +198 -0
  178. package/dist/core/schema-registry.js.map +1 -1
  179. package/dist/core/schemas/connector.d.ts +67 -0
  180. package/dist/core/schemas/connector.js +100 -0
  181. package/dist/core/schemas/connector.js.map +1 -0
  182. package/dist/core/schemas/workspace.d.ts +122 -0
  183. package/dist/core/schemas/workspace.js +211 -0
  184. package/dist/core/schemas/workspace.js.map +1 -0
  185. package/dist/core/secrets/SecretStore.d.ts +77 -0
  186. package/dist/core/secrets/SecretStore.js +13 -0
  187. package/dist/core/secrets/SecretStore.js.map +1 -0
  188. package/dist/core/secrets/SettingsResolver.d.ts +54 -0
  189. package/dist/core/secrets/SettingsResolver.js +80 -0
  190. package/dist/core/secrets/SettingsResolver.js.map +1 -0
  191. package/dist/core/secrets/SettingsStore.d.ts +30 -0
  192. package/dist/core/secrets/SettingsStore.js +9 -0
  193. package/dist/core/secrets/SettingsStore.js.map +1 -0
  194. package/dist/core/secrets/SqliteEncryptedStore.d.ts +90 -0
  195. package/dist/core/secrets/SqliteEncryptedStore.js +598 -0
  196. package/dist/core/secrets/SqliteEncryptedStore.js.map +1 -0
  197. package/dist/core/secrets/audit.d.ts +36 -0
  198. package/dist/core/secrets/audit.js +60 -0
  199. package/dist/core/secrets/audit.js.map +1 -0
  200. package/dist/core/secrets/auth-db-migration.d.ts +129 -0
  201. package/dist/core/secrets/auth-db-migration.js +653 -0
  202. package/dist/core/secrets/auth-db-migration.js.map +1 -0
  203. package/dist/core/secrets/bundle.d.ts +141 -0
  204. package/dist/core/secrets/bundle.js +435 -0
  205. package/dist/core/secrets/bundle.js.map +1 -0
  206. package/dist/core/secrets/dual-write.d.ts +81 -0
  207. package/dist/core/secrets/dual-write.js +247 -0
  208. package/dist/core/secrets/dual-write.js.map +1 -0
  209. package/dist/core/secrets/encryption.d.ts +44 -0
  210. package/dist/core/secrets/encryption.js +97 -0
  211. package/dist/core/secrets/encryption.js.map +1 -0
  212. package/dist/core/secrets/index.d.ts +49 -0
  213. package/dist/core/secrets/index.js +74 -0
  214. package/dist/core/secrets/index.js.map +1 -0
  215. package/dist/core/secrets/master-key.d.ts +38 -0
  216. package/dist/core/secrets/master-key.js +122 -0
  217. package/dist/core/secrets/master-key.js.map +1 -0
  218. package/dist/core/secrets/probes/anthropic.d.ts +98 -0
  219. package/dist/core/secrets/probes/anthropic.js +300 -0
  220. package/dist/core/secrets/probes/anthropic.js.map +1 -0
  221. package/dist/core/secrets/probes/brave.d.ts +9 -0
  222. package/dist/core/secrets/probes/brave.js +15 -0
  223. package/dist/core/secrets/probes/brave.js.map +1 -0
  224. package/dist/core/secrets/probes/r2.d.ts +15 -0
  225. package/dist/core/secrets/probes/r2.js +14 -0
  226. package/dist/core/secrets/probes/r2.js.map +1 -0
  227. package/dist/core/secrets/probes/voyage.d.ts +13 -0
  228. package/dist/core/secrets/probes/voyage.js +52 -0
  229. package/dist/core/secrets/probes/voyage.js.map +1 -0
  230. package/dist/core/secrets/r2-structural-migration.d.ts +39 -0
  231. package/dist/core/secrets/r2-structural-migration.js +109 -0
  232. package/dist/core/secrets/r2-structural-migration.js.map +1 -0
  233. package/dist/core/secrets/read-flip.d.ts +59 -0
  234. package/dist/core/secrets/read-flip.js +87 -0
  235. package/dist/core/secrets/read-flip.js.map +1 -0
  236. package/dist/core/secrets/registry.d.ts +188 -0
  237. package/dist/core/secrets/registry.js +598 -0
  238. package/dist/core/secrets/registry.js.map +1 -0
  239. package/dist/core/types.d.ts +77 -455
  240. package/dist/core/types.js +17 -3
  241. package/dist/core/types.js.map +1 -1
  242. package/dist/core/user-config.d.ts +75 -7
  243. package/dist/core/user-config.js +155 -7
  244. package/dist/core/user-config.js.map +1 -1
  245. package/dist/core/validation.d.ts +717 -1791
  246. package/dist/core/validation.js +357 -228
  247. package/dist/core/validation.js.map +1 -1
  248. package/dist/core/workspace-manager.d.ts +79 -12
  249. package/dist/core/workspace-manager.js +213 -73
  250. package/dist/core/workspace-manager.js.map +1 -1
  251. package/dist/core/workspace.d.ts +19 -5
  252. package/dist/core/workspace.js +69 -35
  253. package/dist/core/workspace.js.map +1 -1
  254. package/dist/mcp/comment-virtual-entity.d.ts +36 -0
  255. package/dist/mcp/comment-virtual-entity.js +71 -0
  256. package/dist/mcp/comment-virtual-entity.js.map +1 -0
  257. package/dist/mcp/connector-manager.d.ts +94 -13
  258. package/dist/mcp/connector-manager.js +283 -62
  259. package/dist/mcp/connector-manager.js.map +1 -1
  260. package/dist/mcp/oauth-provider.d.ts +27 -33
  261. package/dist/mcp/oauth-provider.js +65 -134
  262. package/dist/mcp/oauth-provider.js.map +1 -1
  263. package/dist/mcp/oauth-registry.d.ts +62 -0
  264. package/dist/mcp/oauth-registry.js +85 -0
  265. package/dist/mcp/oauth-registry.js.map +1 -0
  266. package/dist/mcp/server.d.ts +11 -3
  267. package/dist/mcp/server.js +30 -5
  268. package/dist/mcp/server.js.map +1 -1
  269. package/dist/mcp/state-signer.d.ts +41 -0
  270. package/dist/mcp/state-signer.js +120 -0
  271. package/dist/mcp/state-signer.js.map +1 -0
  272. package/dist/mcp/tools.d.ts +35 -2
  273. package/dist/mcp/tools.js +1051 -117
  274. package/dist/mcp/tools.js.map +1 -1
  275. package/dist/server/__tests__/helpers/graph-api.d.ts +18 -0
  276. package/dist/server/__tests__/helpers/graph-api.js +16 -0
  277. package/dist/server/__tests__/helpers/graph-api.js.map +1 -0
  278. package/dist/server/body-write-coordinator.d.ts +151 -0
  279. package/dist/server/body-write-coordinator.js +161 -0
  280. package/dist/server/body-write-coordinator.js.map +1 -0
  281. package/dist/server/cloud-billing-flow.d.ts +32 -0
  282. package/dist/server/cloud-billing-flow.js +75 -0
  283. package/dist/server/cloud-billing-flow.js.map +1 -0
  284. package/dist/server/commit-audit.d.ts +26 -0
  285. package/dist/server/commit-audit.js +30 -0
  286. package/dist/server/commit-audit.js.map +1 -0
  287. package/dist/server/index.d.ts +14 -3
  288. package/dist/server/index.js +451 -193
  289. package/dist/server/index.js.map +1 -1
  290. package/dist/server/middleware/sanitize.d.ts +46 -0
  291. package/dist/server/middleware/sanitize.js +171 -0
  292. package/dist/server/middleware/sanitize.js.map +1 -0
  293. package/dist/server/routes/asset-api.d.ts +10 -0
  294. package/dist/server/routes/asset-api.js +294 -0
  295. package/dist/server/routes/asset-api.js.map +1 -0
  296. package/dist/server/routes/audit-api.d.ts +24 -0
  297. package/dist/server/routes/audit-api.js +117 -0
  298. package/dist/server/routes/audit-api.js.map +1 -0
  299. package/dist/server/routes/auth-api.d.ts +3 -2
  300. package/dist/server/routes/auth-api.js +519 -38
  301. package/dist/server/routes/auth-api.js.map +1 -1
  302. package/dist/server/routes/capture-api.d.ts +18 -0
  303. package/dist/server/routes/capture-api.js +257 -0
  304. package/dist/server/routes/capture-api.js.map +1 -0
  305. package/dist/server/routes/chat.d.ts +4 -1
  306. package/dist/server/routes/chat.js +298 -88
  307. package/dist/server/routes/chat.js.map +1 -1
  308. package/dist/server/routes/comments-api.d.ts +15 -0
  309. package/dist/server/routes/comments-api.js +444 -0
  310. package/dist/server/routes/comments-api.js.map +1 -0
  311. package/dist/server/routes/config-api.d.ts +21 -0
  312. package/dist/server/routes/config-api.js +256 -0
  313. package/dist/server/routes/config-api.js.map +1 -0
  314. package/dist/server/routes/connectors-api.d.ts +17 -0
  315. package/dist/server/routes/connectors-api.js +410 -0
  316. package/dist/server/routes/connectors-api.js.map +1 -0
  317. package/dist/server/routes/event-ingest-api.d.ts +15 -0
  318. package/dist/server/routes/event-ingest-api.js +125 -0
  319. package/dist/server/routes/event-ingest-api.js.map +1 -0
  320. package/dist/server/routes/git-http.d.ts +3 -3
  321. package/dist/server/routes/git-http.js +86 -38
  322. package/dist/server/routes/git-http.js.map +1 -1
  323. package/dist/server/routes/graph-api-access.d.ts +35 -0
  324. package/dist/server/routes/graph-api-access.js +105 -0
  325. package/dist/server/routes/graph-api-access.js.map +1 -0
  326. package/dist/server/routes/graph-api.d.ts +3 -2
  327. package/dist/server/routes/graph-api.js +1321 -263
  328. package/dist/server/routes/graph-api.js.map +1 -1
  329. package/dist/server/routes/health.d.ts +18 -0
  330. package/dist/server/routes/health.js +74 -0
  331. package/dist/server/routes/health.js.map +1 -0
  332. package/dist/server/routes/insights-api.d.ts +1 -2
  333. package/dist/server/routes/insights-api.js +149 -41
  334. package/dist/server/routes/insights-api.js.map +1 -1
  335. package/dist/server/routes/mcp.d.ts +7 -3
  336. package/dist/server/routes/mcp.js +22 -6
  337. package/dist/server/routes/mcp.js.map +1 -1
  338. package/dist/server/routes/permissions-api.d.ts +9 -2
  339. package/dist/server/routes/permissions-api.js +87 -30
  340. package/dist/server/routes/permissions-api.js.map +1 -1
  341. package/dist/server/routes/r2-api.d.ts +25 -0
  342. package/dist/server/routes/r2-api.js +276 -0
  343. package/dist/server/routes/r2-api.js.map +1 -0
  344. package/dist/server/routes/secrets-api.d.ts +36 -0
  345. package/dist/server/routes/secrets-api.js +308 -0
  346. package/dist/server/routes/secrets-api.js.map +1 -0
  347. package/dist/server/routes/settings-api.d.ts +29 -0
  348. package/dist/server/routes/settings-api.js +180 -0
  349. package/dist/server/routes/settings-api.js.map +1 -0
  350. package/dist/server/routes/url-api.d.ts +7 -0
  351. package/dist/server/routes/url-api.js +74 -0
  352. package/dist/server/routes/url-api.js.map +1 -0
  353. package/dist/server/routes/views-api.js +81 -7
  354. package/dist/server/routes/views-api.js.map +1 -1
  355. package/dist/server/routes/workspace-api.d.ts +2 -1
  356. package/dist/server/routes/workspace-api.js +124 -35
  357. package/dist/server/routes/workspace-api.js.map +1 -1
  358. package/dist/server/routes/ws.d.ts +2 -1
  359. package/dist/server/routes/ws.js +64 -16
  360. package/dist/server/routes/ws.js.map +1 -1
  361. package/dist/server/session-manager.d.ts +48 -5
  362. package/dist/server/session-manager.js +182 -15
  363. package/dist/server/session-manager.js.map +1 -1
  364. package/dist/server/ws-hub.d.ts +138 -14
  365. package/dist/server/ws-hub.js +0 -0
  366. package/dist/server/ws-hub.js.map +1 -1
  367. package/dist/server/yjs-manager.d.ts +285 -7
  368. package/dist/server/yjs-manager.js +907 -36
  369. package/dist/server/yjs-manager.js.map +1 -1
  370. package/dist/server/yjs-persistence.d.ts +165 -0
  371. package/dist/server/yjs-persistence.js +557 -0
  372. package/dist/server/yjs-persistence.js.map +1 -0
  373. package/dist/server/yjs-text-diff.d.ts +32 -0
  374. package/dist/server/yjs-text-diff.js +61 -0
  375. package/dist/server/yjs-text-diff.js.map +1 -0
  376. package/dist/services/access-control.d.ts +73 -0
  377. package/dist/services/access-control.js +146 -0
  378. package/dist/services/access-control.js.map +1 -0
  379. package/dist/services/asset-listing.d.ts +39 -0
  380. package/dist/services/asset-listing.js +125 -0
  381. package/dist/services/asset-listing.js.map +1 -0
  382. package/dist/services/asset-upload-service.d.ts +53 -0
  383. package/dist/services/asset-upload-service.js +201 -0
  384. package/dist/services/asset-upload-service.js.map +1 -0
  385. package/dist/services/assets/asset-index-service.d.ts +82 -0
  386. package/dist/services/assets/asset-index-service.js +167 -0
  387. package/dist/services/assets/asset-index-service.js.map +1 -0
  388. package/dist/services/assets/base.d.ts +44 -0
  389. package/dist/services/assets/base.js +55 -0
  390. package/dist/services/assets/base.js.map +1 -1
  391. package/dist/services/assets/index.d.ts +69 -1
  392. package/dist/services/assets/index.js +125 -0
  393. package/dist/services/assets/index.js.map +1 -1
  394. package/dist/services/assets/local.d.ts +12 -1
  395. package/dist/services/assets/local.js +100 -1
  396. package/dist/services/assets/local.js.map +1 -1
  397. package/dist/services/assets/r2-sync.d.ts +88 -0
  398. package/dist/services/assets/r2-sync.js +412 -0
  399. package/dist/services/assets/r2-sync.js.map +1 -0
  400. package/dist/services/assets/r2.d.ts +34 -1
  401. package/dist/services/assets/r2.js +93 -1
  402. package/dist/services/assets/r2.js.map +1 -1
  403. package/dist/services/auth-service.d.ts +314 -36
  404. package/dist/services/auth-service.js +1126 -139
  405. package/dist/services/auth-service.js.map +1 -1
  406. package/dist/services/bookmark-import-service.d.ts +49 -0
  407. package/dist/services/bookmark-import-service.js +250 -0
  408. package/dist/services/bookmark-import-service.js.map +1 -0
  409. package/dist/services/cloud-inference-billing.d.ts +64 -0
  410. package/dist/services/cloud-inference-billing.js +313 -0
  411. package/dist/services/cloud-inference-billing.js.map +1 -0
  412. package/dist/services/comment-service.d.ts +97 -0
  413. package/dist/services/comment-service.js +341 -0
  414. package/dist/services/comment-service.js.map +1 -0
  415. package/dist/services/csv-service.d.ts +22 -17
  416. package/dist/services/csv-service.js +55 -92
  417. package/dist/services/csv-service.js.map +1 -1
  418. package/dist/services/entity-mutations.d.ts +161 -0
  419. package/dist/services/entity-mutations.js +296 -0
  420. package/dist/services/entity-mutations.js.map +1 -0
  421. package/dist/services/event-processor.d.ts +104 -0
  422. package/dist/services/event-processor.js +441 -0
  423. package/dist/services/event-processor.js.map +1 -0
  424. package/dist/services/folder-creation.d.ts +33 -0
  425. package/dist/services/folder-creation.js +103 -0
  426. package/dist/services/folder-creation.js.map +1 -0
  427. package/dist/services/git.d.ts +54 -0
  428. package/dist/services/git.js +188 -54
  429. package/dist/services/git.js.map +1 -1
  430. package/dist/services/graph-maintenance.d.ts +42 -0
  431. package/dist/services/graph-maintenance.js +143 -0
  432. package/dist/services/graph-maintenance.js.map +1 -0
  433. package/dist/services/import-service.d.ts +45 -2
  434. package/dist/services/import-service.js +304 -100
  435. package/dist/services/import-service.js.map +1 -1
  436. package/dist/services/lint-service.js +9 -16
  437. package/dist/services/lint-service.js.map +1 -1
  438. package/dist/services/markdown.d.ts +12 -1
  439. package/dist/services/markdown.js +71 -6
  440. package/dist/services/markdown.js.map +1 -1
  441. package/dist/services/memory-service.d.ts +22 -14
  442. package/dist/services/memory-service.js +53 -35
  443. package/dist/services/memory-service.js.map +1 -1
  444. package/dist/services/mention-detector.d.ts +23 -0
  445. package/dist/services/mention-detector.js +47 -0
  446. package/dist/services/mention-detector.js.map +1 -0
  447. package/dist/services/notification-broadcast.d.ts +32 -0
  448. package/dist/services/notification-broadcast.js +38 -0
  449. package/dist/services/notification-broadcast.js.map +1 -0
  450. package/dist/services/notification-service.d.ts +41 -0
  451. package/dist/services/notification-service.js +100 -0
  452. package/dist/services/notification-service.js.map +1 -0
  453. package/dist/services/personal-folder.d.ts +40 -0
  454. package/dist/services/personal-folder.js +101 -0
  455. package/dist/services/personal-folder.js.map +1 -0
  456. package/dist/services/user-person-bridge.d.ts +118 -0
  457. package/dist/services/user-person-bridge.js +306 -0
  458. package/dist/services/user-person-bridge.js.map +1 -0
  459. package/dist/services/vector-service.d.ts +146 -6
  460. package/dist/services/vector-service.js +276 -13
  461. package/dist/services/vector-service.js.map +1 -1
  462. package/dist/services/workspace-access.d.ts +106 -0
  463. package/dist/services/workspace-access.js +149 -0
  464. package/dist/services/workspace-access.js.map +1 -0
  465. package/dist/utils/git.d.ts +17 -2
  466. package/dist/utils/git.js +23 -7
  467. package/dist/utils/git.js.map +1 -1
  468. package/dist/utils/log.d.ts +42 -0
  469. package/dist/utils/log.js +137 -0
  470. package/dist/utils/log.js.map +1 -0
  471. package/dist/utils/preflight.js +2 -2
  472. package/dist/utils/preflight.js.map +1 -1
  473. package/dist/utils/version-checker.d.ts +12 -19
  474. package/dist/utils/version-checker.js +14 -104
  475. package/dist/utils/version-checker.js.map +1 -1
  476. package/dist/web/_app/immutable/assets/0.DOumm94J.css +2 -0
  477. package/dist/web/_app/immutable/assets/13.DlIf1mKh.css +1 -0
  478. package/dist/web/_app/immutable/assets/14.7d-Q37wc.css +1 -0
  479. package/dist/web/_app/immutable/assets/15.CmLrbzZp.css +1 -0
  480. package/dist/web/_app/immutable/assets/2.BftNiAA4.css +1 -0
  481. package/dist/web/_app/immutable/assets/3.CV0abekd.css +1 -0
  482. package/dist/web/_app/immutable/assets/4.BgXzUTTk.css +1 -0
  483. package/dist/web/_app/immutable/assets/9.gsF-Ne6_.css +1 -0
  484. package/dist/web/_app/immutable/assets/AgentOverlay.CpvH9rhf.css +1 -0
  485. package/dist/web/_app/immutable/assets/AssetManagerModal.BKJfPAPk.css +1 -0
  486. package/dist/web/_app/immutable/assets/CalendarView.bNewSxkH.css +1 -0
  487. package/dist/web/_app/immutable/assets/ChevronDown.Bz0FjM7b.css +1 -0
  488. package/dist/web/_app/immutable/assets/FolderMembersPanel.DX1oBeF_.css +1 -0
  489. package/dist/web/_app/immutable/assets/FolderMutationDialogs.BMRF6Z3z.css +1 -0
  490. package/dist/web/_app/immutable/assets/FolderPicker.B5KdvRYl.css +1 -0
  491. package/dist/web/_app/immutable/assets/GalleryView.DgvSjS0I.css +1 -0
  492. package/dist/web/_app/immutable/assets/GraphView.BJtGL9py.css +1 -0
  493. package/dist/web/_app/immutable/assets/KanbanView.D0QbZFd6.css +1 -0
  494. package/dist/web/_app/immutable/assets/SettingsDialog.DYWYMFSO.css +1 -0
  495. package/dist/web/_app/immutable/assets/Spinner.UBfl0pab.css +1 -0
  496. package/dist/web/_app/immutable/assets/StopFilledAlt.CkPrHdPD.css +1 -0
  497. package/dist/web/_app/immutable/assets/TimelineView.DA-H6dvv.css +1 -0
  498. package/dist/web/_app/immutable/assets/WorkspaceView.BS1ildu7.css +1 -0
  499. package/dist/web/_app/immutable/assets/button.BlLm4Dg1.css +1 -0
  500. package/dist/web/_app/immutable/assets/formatting.vV0ne105.css +1 -0
  501. package/dist/web/_app/immutable/assets/ibm-plex-mono-latin-400-normal.CvHOgSBP.woff +0 -0
  502. package/dist/web/_app/immutable/assets/ibm-plex-mono-latin-400-normal.DMJ8VG8y.woff2 +0 -0
  503. package/dist/web/_app/immutable/assets/ibm-plex-mono-latin-500-normal.CB9ihrfo.woff +0 -0
  504. package/dist/web/_app/immutable/assets/ibm-plex-mono-latin-500-normal.DSY6xOcd.woff2 +0 -0
  505. package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-400-italic.CZTNEAuW.woff2 +0 -0
  506. package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-400-italic.CsGl1sm0.woff +0 -0
  507. package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-400-normal.CDDApCn2.woff2 +0 -0
  508. package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-400-normal.CYLoc0-x.woff +0 -0
  509. package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-500-italic.BNK2_mGO.woff2 +0 -0
  510. package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-500-italic.DpEwFAQM.woff +0 -0
  511. package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-500-normal.6ng42L7E.woff2 +0 -0
  512. package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-500-normal.BgVn5rGT.woff +0 -0
  513. package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-600-normal.Cu4Hd6ag.woff +0 -0
  514. package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-600-normal.CuJfVYMP.woff2 +0 -0
  515. package/dist/web/_app/immutable/assets/inline-list.cqga56xT.css +1 -0
  516. package/dist/web/_app/immutable/assets/list-columns.BrRrAiZ7.css +1 -0
  517. package/dist/web/_app/immutable/assets/realtime.DDCWxn3B.css +1 -0
  518. package/dist/web/_app/immutable/assets/sheet.DJPuqnTg.css +1 -0
  519. package/dist/web/_app/immutable/assets/speech-dictation.C5CYFhS1.css +1 -0
  520. package/dist/web/_app/immutable/chunks/0edH8sO82.js +1 -0
  521. package/dist/web/_app/immutable/chunks/0iDDDyaZ.js +1 -0
  522. package/dist/web/_app/immutable/chunks/7-y0CE1z.js +1 -0
  523. package/dist/web/_app/immutable/chunks/7Bu427my2.js +184 -0
  524. package/dist/web/_app/immutable/chunks/B-V5eMSw2.js +1 -0
  525. package/dist/web/_app/immutable/chunks/B-i9_YVM.js +1 -0
  526. package/dist/web/_app/immutable/chunks/B15-kvCe.js +1 -0
  527. package/dist/web/_app/immutable/chunks/B4cFqqUS.js +3 -0
  528. package/dist/web/_app/immutable/chunks/B6Lt-qaJ.js +1 -0
  529. package/dist/web/_app/immutable/chunks/B7hznOQp.js +1 -0
  530. package/dist/web/_app/immutable/chunks/B7tbGyiU2.js +1 -0
  531. package/dist/web/_app/immutable/chunks/B8-Lm86o2.js +1 -0
  532. package/dist/web/_app/immutable/chunks/BBxt8lpa.js +1 -0
  533. package/dist/web/_app/immutable/chunks/BCaRi2AF.js +1 -0
  534. package/dist/web/_app/immutable/chunks/BJReLGMs.js +1 -0
  535. package/dist/web/_app/immutable/chunks/BKzV82vD2.js +83 -0
  536. package/dist/web/_app/immutable/chunks/BLHrJvbG.js +1 -0
  537. package/dist/web/_app/immutable/chunks/BQULA6er.js +4 -0
  538. package/dist/web/_app/immutable/chunks/BQW-EDgP2.js +224 -0
  539. package/dist/web/_app/immutable/chunks/BR6jMpLB.js +1 -0
  540. package/dist/web/_app/immutable/chunks/BUBMGU8t2.js +2 -0
  541. package/dist/web/_app/immutable/chunks/BVnuLlVg.js +1 -0
  542. package/dist/web/_app/immutable/chunks/BYokDzwE.js +1 -0
  543. package/dist/web/_app/immutable/chunks/B_Avv9jQ2.js +1 -0
  544. package/dist/web/_app/immutable/chunks/Bbp76jaT2.js +1 -0
  545. package/dist/web/_app/immutable/chunks/BddNmQ8j.js +1 -0
  546. package/dist/web/_app/immutable/chunks/BiJjH5mJ.js +2 -0
  547. package/dist/web/_app/immutable/chunks/BlyFGPFV.js +22 -0
  548. package/dist/web/_app/immutable/chunks/Bt88HrRx.js +1 -0
  549. package/dist/web/_app/immutable/chunks/Bu2iU6hh.js +67 -0
  550. package/dist/web/_app/immutable/chunks/Buih1pxI.js +1 -0
  551. package/dist/web/_app/immutable/chunks/BwwLJVvR.js +1 -0
  552. package/dist/web/_app/immutable/chunks/C0RpyyYZ.js +7 -0
  553. package/dist/web/_app/immutable/chunks/C2l-EXsT2.js +1 -0
  554. package/dist/web/_app/immutable/chunks/C35TIzaB.js +1 -0
  555. package/dist/web/_app/immutable/chunks/C3OdK8z_2.js +1 -0
  556. package/dist/web/_app/immutable/chunks/C6Nx0jpM.js +1 -0
  557. package/dist/web/_app/immutable/chunks/CA7O0J_u2.js +1 -0
  558. package/dist/web/_app/immutable/chunks/CAwjrHdk.js +23 -0
  559. package/dist/web/_app/immutable/chunks/CCLR-AJL2.js +185 -0
  560. package/dist/web/_app/immutable/chunks/CD0X7XPW.js +6 -0
  561. package/dist/web/_app/immutable/chunks/CDTjuBSm2.js +1 -0
  562. package/dist/web/_app/immutable/chunks/CSUrYOis.js +1 -0
  563. package/dist/web/_app/immutable/chunks/CUPSJObs.js +1 -0
  564. package/dist/web/_app/immutable/chunks/CUoGvuxK2.js +2 -0
  565. package/dist/web/_app/immutable/chunks/CWh8aehb.js +1 -0
  566. package/dist/web/_app/immutable/chunks/CY4XVBP0.js +1 -0
  567. package/dist/web/_app/immutable/chunks/CYTTTma0.js +3 -0
  568. package/dist/web/_app/immutable/chunks/CaoriuNq.js +1 -0
  569. package/dist/web/_app/immutable/chunks/CcsSt9AW2.js +1 -0
  570. package/dist/web/_app/immutable/chunks/Cd4VfAZC.js +2 -0
  571. package/dist/web/_app/immutable/chunks/CePZ1985.js +1 -0
  572. package/dist/web/_app/immutable/chunks/Cei8CKT4.js +1 -0
  573. package/dist/web/_app/immutable/chunks/CheXrbDk.js +1 -0
  574. package/dist/web/_app/immutable/chunks/CkLfUXwy.js +1 -0
  575. package/dist/web/_app/immutable/chunks/Cl-W0JYt.js +2 -0
  576. package/dist/web/_app/immutable/chunks/Cna7tYp-.js +1 -0
  577. package/dist/web/_app/immutable/chunks/Czwe4sGU2.js +3 -0
  578. package/dist/web/_app/immutable/chunks/D41CbXmM.js +1 -0
  579. package/dist/web/_app/immutable/chunks/D83_Ss42.js +1 -0
  580. package/dist/web/_app/immutable/chunks/D9NgbGbO2.js +9 -0
  581. package/dist/web/_app/immutable/chunks/DA-BuZei.js +2 -0
  582. package/dist/web/_app/immutable/chunks/DAXW5iLp.js +1 -0
  583. package/dist/web/_app/immutable/chunks/DCNJWlho2.js +1 -0
  584. package/dist/web/_app/immutable/chunks/DCtgLRl52.js +1 -0
  585. package/dist/web/_app/immutable/chunks/DH0epITQ2.js +1 -0
  586. package/dist/web/_app/immutable/chunks/DIb5rAPU2.js +1 -0
  587. package/dist/web/_app/immutable/chunks/DOmH4G9o2.js +14 -0
  588. package/dist/web/_app/immutable/chunks/DQMWa2t7.js +1 -0
  589. package/dist/web/_app/immutable/chunks/DQ_Xokap2.js +1 -0
  590. package/dist/web/_app/immutable/chunks/DR1K6PIm.js +1 -0
  591. package/dist/web/_app/immutable/chunks/DTCHhxD5.js +1 -0
  592. package/dist/web/_app/immutable/chunks/DU7XA-cL2.js +1 -0
  593. package/dist/web/_app/immutable/chunks/DVcy5Pp82.js +1 -0
  594. package/dist/web/_app/immutable/chunks/DWygwzcO2.js +1 -0
  595. package/dist/web/_app/immutable/chunks/DYXDRmoo2.js +1 -0
  596. package/dist/web/_app/immutable/chunks/DZEmjBTb.js +5 -0
  597. package/dist/web/_app/immutable/chunks/DaoOQFPb.js +1 -0
  598. package/dist/web/_app/immutable/chunks/DdfvtDOY.js +1 -0
  599. package/dist/web/_app/immutable/chunks/DevZTY1l.js +1 -0
  600. package/dist/web/_app/immutable/chunks/DiMQob8p.js +1 -0
  601. package/dist/web/_app/immutable/chunks/DiQJ9DLy.js +1 -0
  602. package/dist/web/_app/immutable/chunks/Dj5oP_ik.js +2 -0
  603. package/dist/web/_app/immutable/chunks/DjTJ4D_0.js +3 -0
  604. package/dist/web/_app/immutable/chunks/Dk4TeNwu.js +1 -0
  605. package/dist/web/_app/immutable/chunks/Dng3OcRd2.js +1 -0
  606. package/dist/web/_app/immutable/chunks/Do1OCNEG.js +1 -0
  607. package/dist/web/_app/immutable/chunks/Do6qOOng.js +1 -0
  608. package/dist/web/_app/immutable/chunks/DoQwt-f9.js +1 -0
  609. package/dist/web/_app/immutable/chunks/Dqg3k9HI2.js +1 -0
  610. package/dist/web/_app/immutable/chunks/DsnqOT222.js +1 -0
  611. package/dist/web/_app/immutable/chunks/DvJSCDuZ2.js +1 -0
  612. package/dist/web/_app/immutable/chunks/DvPuaTCl.js +5 -0
  613. package/dist/web/_app/immutable/chunks/DvYQu1SV.js +1 -0
  614. package/dist/web/_app/immutable/chunks/HdjeJPly2.js +1 -0
  615. package/dist/web/_app/immutable/chunks/JDw4tOUY.js +1 -0
  616. package/dist/web/_app/immutable/chunks/KBbDg4lS2.js +1 -0
  617. package/dist/web/_app/immutable/chunks/KQXQLJf32.js +1 -0
  618. package/dist/web/_app/immutable/chunks/LGnNalpn2.js +2 -0
  619. package/dist/web/_app/immutable/chunks/SH5Fg0Xu.js +1 -0
  620. package/dist/web/_app/immutable/chunks/Shyla9c12.js +2 -0
  621. package/dist/web/_app/immutable/chunks/VQbAelHN.js +1 -0
  622. package/dist/web/_app/immutable/chunks/apNT0aug2.js +1 -0
  623. package/dist/web/_app/immutable/chunks/e0ySQvQ6.js +1 -0
  624. package/dist/web/_app/immutable/chunks/eNB_sIHC2.js +1 -0
  625. package/dist/web/_app/immutable/chunks/g09ZrevW2.js +1 -0
  626. package/dist/web/_app/immutable/chunks/h4WWBpcD2.js +2692 -0
  627. package/dist/web/_app/immutable/chunks/kLub81jO.js +1 -0
  628. package/dist/web/_app/immutable/chunks/kW0z_eo62.js +1 -0
  629. package/dist/web/_app/immutable/chunks/oBQOivfH.js +1 -0
  630. package/dist/web/_app/immutable/chunks/ss8hcwTT.js +1 -0
  631. package/dist/web/_app/immutable/chunks/sub7ikQV.js +2 -0
  632. package/dist/web/_app/immutable/chunks/uuFM-ygL.js +1 -0
  633. package/dist/web/_app/immutable/chunks/v7me-ydg.js +2 -0
  634. package/dist/web/_app/immutable/chunks/xihTtKlq.js +1 -0
  635. package/dist/web/_app/immutable/chunks/yWi6xIp8.js +1 -0
  636. package/dist/web/_app/immutable/chunks/zl16E4yc2.js +1 -0
  637. package/dist/web/_app/immutable/entry/app.Bgniax9U.js +2 -0
  638. package/dist/web/_app/immutable/entry/start.DeD3x0AC.js +1 -0
  639. package/dist/web/_app/immutable/nodes/0.D7f7eDDY.js +2 -0
  640. package/dist/web/_app/immutable/nodes/1.nDAGsQWA.js +1 -0
  641. package/dist/web/_app/immutable/nodes/10.DFbhcPKT.js +1 -0
  642. package/dist/web/_app/immutable/nodes/11.CDErrkBt.js +1 -0
  643. package/dist/web/_app/immutable/nodes/12.QmphOXE2.js +1 -0
  644. package/dist/web/_app/immutable/nodes/13.Bq5rDhPs.js +1 -0
  645. package/dist/web/_app/immutable/nodes/14.BlcHqgr6.js +1 -0
  646. package/dist/web/_app/immutable/nodes/15.COK_-D-x.js +1 -0
  647. package/dist/web/_app/immutable/nodes/2.DOsCYNHa.js +109 -0
  648. package/dist/web/_app/immutable/nodes/3.BgE1sIyr.js +2 -0
  649. package/dist/web/_app/immutable/nodes/4.DvgaHeI4.js +11 -0
  650. package/dist/web/_app/immutable/nodes/5.CrjeBZES.js +1 -0
  651. package/dist/web/_app/immutable/nodes/6.DoHxsiEE.js +1 -0
  652. package/dist/web/_app/immutable/nodes/7.04fnxKIo.js +1 -0
  653. package/dist/web/_app/immutable/nodes/8.D35_cY6C.js +1 -0
  654. package/dist/web/_app/immutable/nodes/9.BRionwW6.js +1 -0
  655. package/dist/web/_app/version.json +1 -1
  656. package/dist/web/favicon-16.png +0 -0
  657. package/dist/web/favicon-180.png +0 -0
  658. package/dist/web/favicon-32.png +0 -0
  659. package/dist/web/favicon-48.png +0 -0
  660. package/dist/web/favicon-512.png +0 -0
  661. package/dist/web/favicon.ico +0 -0
  662. package/dist/web/favicon.svg +8 -0
  663. package/dist/web/index.html +56 -19
  664. package/dist/web/studiograph-logomark.svg +29 -0
  665. package/package.json +34 -14
  666. package/dist/agent/skills/enrich-entities.md +0 -136
  667. package/dist/agent/skills/obsidian-source-setup.md +0 -246
  668. package/dist/agent/skills/skill-loader.d.ts +0 -48
  669. package/dist/agent/skills/skill-loader.js +0 -166
  670. package/dist/agent/skills/skill-loader.js.map +0 -1
  671. package/dist/agent/skills/sync-configuration.md +0 -119
  672. package/dist/agent/skills/sync-setup.md +0 -82
  673. package/dist/agent/tools/sync-tools.d.ts +0 -35
  674. package/dist/agent/tools/sync-tools.js +0 -992
  675. package/dist/agent/tools/sync-tools.js.map +0 -1
  676. package/dist/auth/github.d.ts +0 -56
  677. package/dist/auth/github.js +0 -169
  678. package/dist/auth/github.js.map +0 -1
  679. package/dist/cli/commands/access.d.ts +0 -11
  680. package/dist/cli/commands/access.js +0 -156
  681. package/dist/cli/commands/access.js.map +0 -1
  682. package/dist/cli/commands/app.d.ts +0 -7
  683. package/dist/cli/commands/app.js +0 -268
  684. package/dist/cli/commands/app.js.map +0 -1
  685. package/dist/cli/commands/auth.d.ts +0 -10
  686. package/dist/cli/commands/auth.js +0 -79
  687. package/dist/cli/commands/auth.js.map +0 -1
  688. package/dist/cli/commands/clear.d.ts +0 -5
  689. package/dist/cli/commands/clear.js +0 -36
  690. package/dist/cli/commands/clear.js.map +0 -1
  691. package/dist/cli/commands/collection.d.ts +0 -10
  692. package/dist/cli/commands/collection.js +0 -187
  693. package/dist/cli/commands/collection.js.map +0 -1
  694. package/dist/cli/commands/enrich.d.ts +0 -11
  695. package/dist/cli/commands/enrich.js +0 -135
  696. package/dist/cli/commands/enrich.js.map +0 -1
  697. package/dist/cli/commands/graphrag.d.ts +0 -12
  698. package/dist/cli/commands/graphrag.js +0 -122
  699. package/dist/cli/commands/graphrag.js.map +0 -1
  700. package/dist/cli/commands/join.d.ts +0 -9
  701. package/dist/cli/commands/join.js +0 -255
  702. package/dist/cli/commands/join.js.map +0 -1
  703. package/dist/cli/commands/members.d.ts +0 -11
  704. package/dist/cli/commands/members.js +0 -230
  705. package/dist/cli/commands/members.js.map +0 -1
  706. package/dist/cli/commands/org.d.ts +0 -10
  707. package/dist/cli/commands/org.js +0 -132
  708. package/dist/cli/commands/org.js.map +0 -1
  709. package/dist/cli/commands/provision.d.ts +0 -8
  710. package/dist/cli/commands/provision.js +0 -116
  711. package/dist/cli/commands/provision.js.map +0 -1
  712. package/dist/cli/commands/resolve.d.ts +0 -8
  713. package/dist/cli/commands/resolve.js +0 -85
  714. package/dist/cli/commands/resolve.js.map +0 -1
  715. package/dist/cli/commands/review.d.ts +0 -19
  716. package/dist/cli/commands/review.js +0 -128
  717. package/dist/cli/commands/review.js.map +0 -1
  718. package/dist/cli/commands/source.d.ts +0 -16
  719. package/dist/cli/commands/source.js +0 -159
  720. package/dist/cli/commands/source.js.map +0 -1
  721. package/dist/cli/commands/start.d.ts +0 -7
  722. package/dist/cli/commands/start.js +0 -589
  723. package/dist/cli/commands/start.js.map +0 -1
  724. package/dist/cli/commands/sync-collection.d.ts +0 -14
  725. package/dist/cli/commands/sync-collection.js +0 -355
  726. package/dist/cli/commands/sync-collection.js.map +0 -1
  727. package/dist/cli/commands/sync-entities.d.ts +0 -13
  728. package/dist/cli/commands/sync-entities.js +0 -242
  729. package/dist/cli/commands/sync-entities.js.map +0 -1
  730. package/dist/cli/commands/team.d.ts +0 -12
  731. package/dist/cli/commands/team.js +0 -185
  732. package/dist/cli/commands/team.js.map +0 -1
  733. package/dist/cli/commands/update.d.ts +0 -8
  734. package/dist/cli/commands/update.js +0 -155
  735. package/dist/cli/commands/update.js.map +0 -1
  736. package/dist/cli/commands/user.d.ts +0 -7
  737. package/dist/cli/commands/user.js +0 -153
  738. package/dist/cli/commands/user.js.map +0 -1
  739. package/dist/cli/scaffolding.d.ts +0 -12
  740. package/dist/cli/scaffolding.js +0 -397
  741. package/dist/cli/scaffolding.js.map +0 -1
  742. package/dist/cli/sync-review-interactive.d.ts +0 -31
  743. package/dist/cli/sync-review-interactive.js +0 -393
  744. package/dist/cli/sync-review-interactive.js.map +0 -1
  745. package/dist/core/migration-runner.d.ts +0 -42
  746. package/dist/core/migration-runner.js +0 -232
  747. package/dist/core/migration-runner.js.map +0 -1
  748. package/dist/core/migration-types.d.ts +0 -101
  749. package/dist/core/migration-types.js +0 -21
  750. package/dist/core/migration-types.js.map +0 -1
  751. package/dist/core/migrations/20260219-formalize-memory-location.d.ts +0 -2
  752. package/dist/core/migrations/20260219-formalize-memory-location.js +0 -35
  753. package/dist/core/migrations/20260219-formalize-memory-location.js.map +0 -1
  754. package/dist/core/migrations/20260220-add-workspace-metadata.d.ts +0 -12
  755. package/dist/core/migrations/20260220-add-workspace-metadata.js +0 -65
  756. package/dist/core/migrations/20260220-add-workspace-metadata.js.map +0 -1
  757. package/dist/core/migrations/20260220-add-workspace-readme.d.ts +0 -11
  758. package/dist/core/migrations/20260220-add-workspace-readme.js +0 -82
  759. package/dist/core/migrations/20260220-add-workspace-readme.js.map +0 -1
  760. package/dist/core/migrations/20260220-migrate-yaml-to-json.d.ts +0 -9
  761. package/dist/core/migrations/20260220-migrate-yaml-to-json.js +0 -64
  762. package/dist/core/migrations/20260220-migrate-yaml-to-json.js.map +0 -1
  763. package/dist/core/migrations/index.d.ts +0 -11
  764. package/dist/core/migrations/index.js +0 -23
  765. package/dist/core/migrations/index.js.map +0 -1
  766. package/dist/integrations/asana.d.ts +0 -26
  767. package/dist/integrations/asana.js +0 -78
  768. package/dist/integrations/asana.js.map +0 -1
  769. package/dist/integrations/figma-local.d.ts +0 -16
  770. package/dist/integrations/figma-local.js +0 -10
  771. package/dist/integrations/figma-local.js.map +0 -1
  772. package/dist/integrations/figma.d.ts +0 -17
  773. package/dist/integrations/figma.js +0 -17
  774. package/dist/integrations/figma.js.map +0 -1
  775. package/dist/integrations/granola.d.ts +0 -24
  776. package/dist/integrations/granola.js +0 -60
  777. package/dist/integrations/granola.js.map +0 -1
  778. package/dist/integrations/linear.d.ts +0 -14
  779. package/dist/integrations/linear.js +0 -80
  780. package/dist/integrations/linear.js.map +0 -1
  781. package/dist/integrations/paper-local.d.ts +0 -14
  782. package/dist/integrations/paper-local.js +0 -10
  783. package/dist/integrations/paper-local.js.map +0 -1
  784. package/dist/integrations/paper.d.ts +0 -2
  785. package/dist/integrations/paper.js +0 -10
  786. package/dist/integrations/paper.js.map +0 -1
  787. package/dist/integrations/pipedrive.d.ts +0 -26
  788. package/dist/integrations/pipedrive.js +0 -97
  789. package/dist/integrations/pipedrive.js.map +0 -1
  790. package/dist/integrations/registry.d.ts +0 -15
  791. package/dist/integrations/registry.js +0 -27
  792. package/dist/integrations/registry.js.map +0 -1
  793. package/dist/integrations/types.d.ts +0 -38
  794. package/dist/integrations/types.js +0 -9
  795. package/dist/integrations/types.js.map +0 -1
  796. package/dist/lib/lib/utils.d.ts +0 -2
  797. package/dist/lib/lib/utils.js +0 -6
  798. package/dist/lib/lib/utils.js.map +0 -1
  799. package/dist/mcp/connectors/asana.d.ts +0 -2
  800. package/dist/mcp/connectors/asana.js +0 -20
  801. package/dist/mcp/connectors/asana.js.map +0 -1
  802. package/dist/mcp/connectors/definitions.d.ts +0 -45
  803. package/dist/mcp/connectors/definitions.js +0 -32
  804. package/dist/mcp/connectors/definitions.js.map +0 -1
  805. package/dist/mcp/connectors/figma.d.ts +0 -5
  806. package/dist/mcp/connectors/figma.js +0 -21
  807. package/dist/mcp/connectors/figma.js.map +0 -1
  808. package/dist/mcp/connectors/gdrive.d.ts +0 -2
  809. package/dist/mcp/connectors/gdrive.js +0 -20
  810. package/dist/mcp/connectors/gdrive.js.map +0 -1
  811. package/dist/mcp/connectors/granola.d.ts +0 -2
  812. package/dist/mcp/connectors/granola.js +0 -12
  813. package/dist/mcp/connectors/granola.js.map +0 -1
  814. package/dist/mcp/connectors/linear.d.ts +0 -2
  815. package/dist/mcp/connectors/linear.js +0 -19
  816. package/dist/mcp/connectors/linear.js.map +0 -1
  817. package/dist/mcp/connectors/obsidian.d.ts +0 -2
  818. package/dist/mcp/connectors/obsidian.js +0 -19
  819. package/dist/mcp/connectors/obsidian.js.map +0 -1
  820. package/dist/mcp/connectors/pipedrive.d.ts +0 -2
  821. package/dist/mcp/connectors/pipedrive.js +0 -20
  822. package/dist/mcp/connectors/pipedrive.js.map +0 -1
  823. package/dist/mcp/connectors/slack.d.ts +0 -2
  824. package/dist/mcp/connectors/slack.js +0 -21
  825. package/dist/mcp/connectors/slack.js.map +0 -1
  826. package/dist/mcp/server-oauth-provider.d.ts +0 -56
  827. package/dist/mcp/server-oauth-provider.js +0 -142
  828. package/dist/mcp/server-oauth-provider.js.map +0 -1
  829. package/dist/server/chrome/chrome.css +0 -691
  830. package/dist/server/chrome/chrome.js +0 -374
  831. package/dist/server/collab-authority.d.ts +0 -70
  832. package/dist/server/collab-authority.js +0 -218
  833. package/dist/server/collab-authority.js.map +0 -1
  834. package/dist/server/collab.d.ts +0 -29
  835. package/dist/server/collab.js +0 -195
  836. package/dist/server/collab.js.map +0 -1
  837. package/dist/server/commit-scheduler.d.ts +0 -39
  838. package/dist/server/commit-scheduler.js +0 -113
  839. package/dist/server/commit-scheduler.js.map +0 -1
  840. package/dist/server/plugin-loader.d.ts +0 -53
  841. package/dist/server/plugin-loader.js +0 -155
  842. package/dist/server/plugin-loader.js.map +0 -1
  843. package/dist/server/routes/collab.d.ts +0 -6
  844. package/dist/server/routes/collab.js +0 -10
  845. package/dist/server/routes/collab.js.map +0 -1
  846. package/dist/server/routes/git-api.d.ts +0 -9
  847. package/dist/server/routes/git-api.js +0 -82
  848. package/dist/server/routes/git-api.js.map +0 -1
  849. package/dist/server/routes/meeting-ingest-api.d.ts +0 -15
  850. package/dist/server/routes/meeting-ingest-api.js +0 -323
  851. package/dist/server/routes/meeting-ingest-api.js.map +0 -1
  852. package/dist/server/routes/sync-api.d.ts +0 -26
  853. package/dist/server/routes/sync-api.js +0 -814
  854. package/dist/server/routes/sync-api.js.map +0 -1
  855. package/dist/server/routes/webhook.d.ts +0 -14
  856. package/dist/server/routes/webhook.js +0 -102
  857. package/dist/server/routes/webhook.js.map +0 -1
  858. package/dist/services/github-provisioner.d.ts +0 -36
  859. package/dist/services/github-provisioner.js +0 -126
  860. package/dist/services/github-provisioner.js.map +0 -1
  861. package/dist/services/github-service.d.ts +0 -99
  862. package/dist/services/github-service.js +0 -310
  863. package/dist/services/github-service.js.map +0 -1
  864. package/dist/services/insights.d.ts +0 -38
  865. package/dist/services/insights.js +0 -246
  866. package/dist/services/insights.js.map +0 -1
  867. package/dist/services/sync/collection-sync.d.ts +0 -60
  868. package/dist/services/sync/collection-sync.js +0 -509
  869. package/dist/services/sync/collection-sync.js.map +0 -1
  870. package/dist/services/sync/commit.d.ts +0 -60
  871. package/dist/services/sync/commit.js +0 -354
  872. package/dist/services/sync/commit.js.map +0 -1
  873. package/dist/services/sync/context-index.d.ts +0 -69
  874. package/dist/services/sync/context-index.js +0 -280
  875. package/dist/services/sync/context-index.js.map +0 -1
  876. package/dist/services/sync/data-fetcher.d.ts +0 -31
  877. package/dist/services/sync/data-fetcher.js +0 -12
  878. package/dist/services/sync/data-fetcher.js.map +0 -1
  879. package/dist/services/sync/derive.d.ts +0 -34
  880. package/dist/services/sync/derive.js +0 -164
  881. package/dist/services/sync/derive.js.map +0 -1
  882. package/dist/services/sync/enrichment-state.d.ts +0 -31
  883. package/dist/services/sync/enrichment-state.js +0 -63
  884. package/dist/services/sync/enrichment-state.js.map +0 -1
  885. package/dist/services/sync/enrichment.d.ts +0 -25
  886. package/dist/services/sync/enrichment.js +0 -121
  887. package/dist/services/sync/enrichment.js.map +0 -1
  888. package/dist/services/sync/entity-refresh.d.ts +0 -30
  889. package/dist/services/sync/entity-refresh.js +0 -275
  890. package/dist/services/sync/entity-refresh.js.map +0 -1
  891. package/dist/services/sync/frontmatter-extractor.d.ts +0 -40
  892. package/dist/services/sync/frontmatter-extractor.js +0 -279
  893. package/dist/services/sync/frontmatter-extractor.js.map +0 -1
  894. package/dist/services/sync/graph-match-state.d.ts +0 -33
  895. package/dist/services/sync/graph-match-state.js +0 -61
  896. package/dist/services/sync/graph-match-state.js.map +0 -1
  897. package/dist/services/sync/graph-match.d.ts +0 -53
  898. package/dist/services/sync/graph-match.js +0 -316
  899. package/dist/services/sync/graph-match.js.map +0 -1
  900. package/dist/services/sync/graphrag-client.d.ts +0 -43
  901. package/dist/services/sync/graphrag-client.js +0 -94
  902. package/dist/services/sync/graphrag-client.js.map +0 -1
  903. package/dist/services/sync/graphrag-config.d.ts +0 -16
  904. package/dist/services/sync/graphrag-config.js +0 -39
  905. package/dist/services/sync/graphrag-config.js.map +0 -1
  906. package/dist/services/sync/graphrag-context.d.ts +0 -14
  907. package/dist/services/sync/graphrag-context.js +0 -109
  908. package/dist/services/sync/graphrag-context.js.map +0 -1
  909. package/dist/services/sync/graphrag-indexer.d.ts +0 -30
  910. package/dist/services/sync/graphrag-indexer.js +0 -358
  911. package/dist/services/sync/graphrag-indexer.js.map +0 -1
  912. package/dist/services/sync/llm.d.ts +0 -32
  913. package/dist/services/sync/llm.js +0 -115
  914. package/dist/services/sync/llm.js.map +0 -1
  915. package/dist/services/sync/mcp-client.d.ts +0 -71
  916. package/dist/services/sync/mcp-client.js +0 -299
  917. package/dist/services/sync/mcp-client.js.map +0 -1
  918. package/dist/services/sync/model-factory.d.ts +0 -13
  919. package/dist/services/sync/model-factory.js +0 -38
  920. package/dist/services/sync/model-factory.js.map +0 -1
  921. package/dist/services/sync/name-quality.d.ts +0 -31
  922. package/dist/services/sync/name-quality.js +0 -60
  923. package/dist/services/sync/name-quality.js.map +0 -1
  924. package/dist/services/sync/output-schemas.d.ts +0 -92
  925. package/dist/services/sync/output-schemas.js +0 -43
  926. package/dist/services/sync/output-schemas.js.map +0 -1
  927. package/dist/services/sync/prompts.d.ts +0 -19
  928. package/dist/services/sync/prompts.js +0 -128
  929. package/dist/services/sync/prompts.js.map +0 -1
  930. package/dist/services/sync/reconciler.d.ts +0 -48
  931. package/dist/services/sync/reconciler.js +0 -294
  932. package/dist/services/sync/reconciler.js.map +0 -1
  933. package/dist/services/sync/rest-client.d.ts +0 -40
  934. package/dist/services/sync/rest-client.js +0 -100
  935. package/dist/services/sync/rest-client.js.map +0 -1
  936. package/dist/services/sync/source-config.d.ts +0 -67
  937. package/dist/services/sync/source-config.js +0 -304
  938. package/dist/services/sync/source-config.js.map +0 -1
  939. package/dist/services/sync/source-definitions/asana.d.ts +0 -14
  940. package/dist/services/sync/source-definitions/asana.js +0 -42
  941. package/dist/services/sync/source-definitions/asana.js.map +0 -1
  942. package/dist/services/sync/source-definitions/definitions.d.ts +0 -8
  943. package/dist/services/sync/source-definitions/definitions.js +0 -8
  944. package/dist/services/sync/source-definitions/definitions.js.map +0 -1
  945. package/dist/services/sync/source-definitions/gdrive.d.ts +0 -16
  946. package/dist/services/sync/source-definitions/gdrive.js +0 -68
  947. package/dist/services/sync/source-definitions/gdrive.js.map +0 -1
  948. package/dist/services/sync/source-definitions/granola.d.ts +0 -2
  949. package/dist/services/sync/source-definitions/granola.js +0 -21
  950. package/dist/services/sync/source-definitions/granola.js.map +0 -1
  951. package/dist/services/sync/source-definitions/linear.d.ts +0 -2
  952. package/dist/services/sync/source-definitions/linear.js +0 -62
  953. package/dist/services/sync/source-definitions/linear.js.map +0 -1
  954. package/dist/services/sync/source-definitions/obsidian.d.ts +0 -2
  955. package/dist/services/sync/source-definitions/obsidian.js +0 -55
  956. package/dist/services/sync/source-definitions/obsidian.js.map +0 -1
  957. package/dist/services/sync/source-definitions/pipedrive.d.ts +0 -2
  958. package/dist/services/sync/source-definitions/pipedrive.js +0 -43
  959. package/dist/services/sync/source-definitions/pipedrive.js.map +0 -1
  960. package/dist/services/sync/staging.d.ts +0 -53
  961. package/dist/services/sync/staging.js +0 -130
  962. package/dist/services/sync/staging.js.map +0 -1
  963. package/dist/services/sync/structured-extractor.d.ts +0 -57
  964. package/dist/services/sync/structured-extractor.js +0 -584
  965. package/dist/services/sync/structured-extractor.js.map +0 -1
  966. package/dist/services/sync/sync-runner.d.ts +0 -32
  967. package/dist/services/sync/sync-runner.js +0 -195
  968. package/dist/services/sync/sync-runner.js.map +0 -1
  969. package/dist/services/sync/sync-state.d.ts +0 -43
  970. package/dist/services/sync/sync-state.js +0 -154
  971. package/dist/services/sync/sync-state.js.map +0 -1
  972. package/dist/services/sync/types.d.ts +0 -381
  973. package/dist/services/sync/types.js +0 -8
  974. package/dist/services/sync/types.js.map +0 -1
  975. package/dist/services/sync/unstructured-extractor.d.ts +0 -27
  976. package/dist/services/sync/unstructured-extractor.js +0 -185
  977. package/dist/services/sync/unstructured-extractor.js.map +0 -1
  978. package/dist/utils/merge-resolver.d.ts +0 -59
  979. package/dist/utils/merge-resolver.js +0 -303
  980. package/dist/utils/merge-resolver.js.map +0 -1
  981. package/dist/utils/workspace-config.d.ts +0 -8
  982. package/dist/utils/workspace-config.js +0 -22
  983. package/dist/utils/workspace-config.js.map +0 -1
  984. package/dist/web/_app/immutable/assets/0.DdizXOKi.css +0 -1
  985. package/dist/web/_app/immutable/assets/10.BUrHkYry.css +0 -1
  986. package/dist/web/_app/immutable/assets/2.n7-yW_Fs.css +0 -1
  987. package/dist/web/_app/immutable/assets/3.BtaZagnv.css +0 -1
  988. package/dist/web/_app/immutable/assets/4.DT7X0x5S.css +0 -1
  989. package/dist/web/_app/immutable/assets/5.DFeGxLYf.css +0 -1
  990. package/dist/web/_app/immutable/assets/6.CquhUpOu.css +0 -1
  991. package/dist/web/_app/immutable/assets/7.CjbDRbuG.css +0 -1
  992. package/dist/web/_app/immutable/assets/8.S1QdUGNM.css +0 -1
  993. package/dist/web/_app/immutable/assets/9.CT4xL4K3.css +0 -1
  994. package/dist/web/_app/immutable/assets/Copy.FS8bdfxr.css +0 -1
  995. package/dist/web/_app/immutable/assets/Toaster.rN8r_Hzv.css +0 -1
  996. package/dist/web/_app/immutable/assets/ViewToolbar.D-QW2oKe.css +0 -1
  997. package/dist/web/_app/immutable/assets/index.D6tMDJWk.css +0 -1
  998. package/dist/web/_app/immutable/chunks/37NdyH6G.js +0 -32
  999. package/dist/web/_app/immutable/chunks/4Hhzb6pv.js +0 -1
  1000. package/dist/web/_app/immutable/chunks/72bZS3G9.js +0 -1
  1001. package/dist/web/_app/immutable/chunks/7S2LGqoP.js +0 -2
  1002. package/dist/web/_app/immutable/chunks/B-7hHz7B.js +0 -1
  1003. package/dist/web/_app/immutable/chunks/B340SUKR.js +0 -1
  1004. package/dist/web/_app/immutable/chunks/BB_5th5W.js +0 -3383
  1005. package/dist/web/_app/immutable/chunks/BDoWDZaG.js +0 -1
  1006. package/dist/web/_app/immutable/chunks/BFyl1ZHO.js +0 -1
  1007. package/dist/web/_app/immutable/chunks/BS2rFAVq.js +0 -1
  1008. package/dist/web/_app/immutable/chunks/BSsDC_p4.js +0 -1
  1009. package/dist/web/_app/immutable/chunks/BTX2Jr8_.js +0 -1
  1010. package/dist/web/_app/immutable/chunks/BWNY8zw6.js +0 -2
  1011. package/dist/web/_app/immutable/chunks/BdPUVvAi.js +0 -1
  1012. package/dist/web/_app/immutable/chunks/BmsJVLyy.js +0 -1
  1013. package/dist/web/_app/immutable/chunks/BoAosqzE.js +0 -1
  1014. package/dist/web/_app/immutable/chunks/Bs-tL5OR.js +0 -1
  1015. package/dist/web/_app/immutable/chunks/Bsacq2UX.js +0 -1
  1016. package/dist/web/_app/immutable/chunks/BuewkNdS.js +0 -4
  1017. package/dist/web/_app/immutable/chunks/ByunV8un.js +0 -1
  1018. package/dist/web/_app/immutable/chunks/C7VoTosa.js +0 -2
  1019. package/dist/web/_app/immutable/chunks/CDV5Q7RN.js +0 -1
  1020. package/dist/web/_app/immutable/chunks/CJ8__CWt.js +0 -1
  1021. package/dist/web/_app/immutable/chunks/CJUKmosC.js +0 -5
  1022. package/dist/web/_app/immutable/chunks/CSVbGg_b.js +0 -5
  1023. package/dist/web/_app/immutable/chunks/CT6oIU-C.js +0 -1
  1024. package/dist/web/_app/immutable/chunks/CTRxNfZk.js +0 -1
  1025. package/dist/web/_app/immutable/chunks/CWQRQXxy.js +0 -1
  1026. package/dist/web/_app/immutable/chunks/CXH6iFbA.js +0 -1
  1027. package/dist/web/_app/immutable/chunks/CX_61fY8.js +0 -5
  1028. package/dist/web/_app/immutable/chunks/CYrVHOHA.js +0 -1
  1029. package/dist/web/_app/immutable/chunks/CdeYcEuU.js +0 -1
  1030. package/dist/web/_app/immutable/chunks/CmCtpZ1Y.js +0 -1
  1031. package/dist/web/_app/immutable/chunks/CqkleIqs.js +0 -1
  1032. package/dist/web/_app/immutable/chunks/CrdV0ttd.js +0 -1
  1033. package/dist/web/_app/immutable/chunks/CscoF-YL.js +0 -18
  1034. package/dist/web/_app/immutable/chunks/Cx4YdFMk.js +0 -1
  1035. package/dist/web/_app/immutable/chunks/D7ZqAKi9.js +0 -3
  1036. package/dist/web/_app/immutable/chunks/DGbA7IXh.js +0 -2
  1037. package/dist/web/_app/immutable/chunks/DbbB6Up5.js +0 -1
  1038. package/dist/web/_app/immutable/chunks/DnL9f0lc.js +0 -7
  1039. package/dist/web/_app/immutable/chunks/DtM5cEDu.js +0 -1
  1040. package/dist/web/_app/immutable/chunks/Eqahi7_S.js +0 -2
  1041. package/dist/web/_app/immutable/chunks/FFlLQIiS.js +0 -1
  1042. package/dist/web/_app/immutable/chunks/O9Eb9k00.js +0 -1
  1043. package/dist/web/_app/immutable/chunks/PPVm8Dsz.js +0 -1
  1044. package/dist/web/_app/immutable/chunks/QF8MAmnc.js +0 -1
  1045. package/dist/web/_app/immutable/chunks/UjzxCpxn.js +0 -1
  1046. package/dist/web/_app/immutable/chunks/WSUKABI_.js +0 -1
  1047. package/dist/web/_app/immutable/chunks/Y90OgS-9.js +0 -1
  1048. package/dist/web/_app/immutable/chunks/cLyZlkXv.js +0 -1
  1049. package/dist/web/_app/immutable/chunks/iDtAARVW.js +0 -2
  1050. package/dist/web/_app/immutable/chunks/oC_W7W7p.js +0 -1
  1051. package/dist/web/_app/immutable/chunks/rJdWIgh-.js +0 -1
  1052. package/dist/web/_app/immutable/chunks/tvOsokaa.js +0 -1
  1053. package/dist/web/_app/immutable/chunks/uLEXaPwE.js +0 -1
  1054. package/dist/web/_app/immutable/chunks/vSaooFHB.js +0 -1
  1055. package/dist/web/_app/immutable/entry/app.DAR-mtfg.js +0 -2
  1056. package/dist/web/_app/immutable/entry/start.xDnyRaR1.js +0 -1
  1057. package/dist/web/_app/immutable/nodes/0.D0aV-Zro.js +0 -2
  1058. package/dist/web/_app/immutable/nodes/1.C7QoM8qD.js +0 -1
  1059. package/dist/web/_app/immutable/nodes/10.DrDOAjOD.js +0 -1
  1060. package/dist/web/_app/immutable/nodes/2.B0OgNuKW.js +0 -168
  1061. package/dist/web/_app/immutable/nodes/3.BZ4qHGZi.js +0 -1
  1062. package/dist/web/_app/immutable/nodes/4.hm-mxuuj.js +0 -12
  1063. package/dist/web/_app/immutable/nodes/5.Bdbt4fjx.js +0 -4
  1064. package/dist/web/_app/immutable/nodes/6.BXrCI40c.js +0 -2
  1065. package/dist/web/_app/immutable/nodes/7.Chd4i9bm.js +0 -2
  1066. package/dist/web/_app/immutable/nodes/8.B8SF-aNs.js +0 -1
  1067. package/dist/web/_app/immutable/nodes/9.YDeu-RnO.js +0 -1
@@ -4,25 +4,119 @@
4
4
  * Provides unified entity management across any repo type.
5
5
  * Integrates Git, Markdown, and Asset services.
6
6
  */
7
- import { join, dirname } from 'path';
8
- import { existsSync, readdirSync, statSync, mkdirSync } from 'fs';
7
+ import { join, dirname, relative } from 'path';
8
+ import { existsSync, readdirSync, rmSync, statSync, writeFileSync, mkdirSync, renameSync } from 'fs';
9
+ import { createHash, randomUUID } from 'node:crypto';
9
10
  import { GitService } from '../services/git.js';
10
11
  import { MarkdownService } from '../services/markdown.js';
11
- import { AssetService } from '../services/assets/index.js';
12
- import { ENTITY_SCHEMAS, EntityBaseSchema, SINGULAR_TO_PLURAL, PLURAL_TO_SINGULAR } from './validation.js';
12
+ import { AssetService, publicUrlFor, publicUrlForAssetId } from '../services/assets/index.js';
13
+ import { ENTITY_SCHEMAS, EntityBaseSchema, PLURAL_TO_SINGULAR, assertValidTaskDates } from './validation.js';
13
14
  import { CSVService } from '../services/csv-service.js';
15
+ import { commitMessage as commitMsg, titleCaseType } from './commit-messages.js';
16
+ import { computeChange, parseFrontmatterCacheFree } from './history-diff.js';
17
+ import { folderPathFromFile, normalizeFolderPath, validateFolderPath, InvalidFolderPathError } from './folder-paths.js';
18
+ import { FOLDER_SIDECAR, readFolderSidecar, readFolderSidecarData, readFolderSidecarId, writeFolderSidecar, writeFolderSidecarData } from './folder-sidecar.js';
14
19
  import { z } from 'zod';
20
+ /** Names skipped during recursive repo walks. */
21
+ const WALK_IGNORE = new Set(['.git', '.studiograph', 'node_modules']);
22
+ /**
23
+ * Compute the canonical revision hash for a body string. Used by the
24
+ * Phase 2 baseRevision precondition. SHA-256 hex (64 chars), matches the
25
+ * format Phase 3's `X-Studiograph-Base-Body-Hash` header expects.
26
+ */
27
+ function sha256OfBody(body) {
28
+ return createHash('sha256').update(body, 'utf-8').digest('hex');
29
+ }
30
+ /**
31
+ * Thrown by `graph.update` when the git commit fails after the disk write
32
+ * has already landed. The .md is updated and observable to subsequent
33
+ * readers via `graph.get`, but no commit exists — the repo is dirty.
34
+ *
35
+ * The caller MUST surface this typed error so an operator can recover
36
+ * (manual `git add && git commit`, or `git checkout -- <path>` to revert
37
+ * disk). v3-of-the-plan claim of "auto-convergence via sessionManager"
38
+ * was false outside the autosave PUT path; Phase 2 explicitly registers
39
+ * server-side dirty files with sessionManager when available, but the
40
+ * caller still gets the typed error so it knows the write was partial.
41
+ */
42
+ export class CommitFailedDirtyError extends Error {
43
+ entityType;
44
+ entityId;
45
+ path;
46
+ code = 'COMMIT_FAILED_DIRTY';
47
+ constructor(entityType, entityId, path, cause) {
48
+ const causeMsg = cause instanceof Error ? cause.message : String(cause);
49
+ super(`Commit failed after disk write for ${entityType}/${entityId} (${path}). ` +
50
+ `Disk is updated but uncommitted (dirty). Recover with \`git add ${path} && git commit\` ` +
51
+ `or \`git checkout -- ${path}\` to revert. Underlying error: ${causeMsg}`);
52
+ this.entityType = entityType;
53
+ this.entityId = entityId;
54
+ this.path = path;
55
+ this.name = 'CommitFailedDirtyError';
56
+ this.cause = cause;
57
+ }
58
+ }
59
+ /**
60
+ * Thrown by `graph.update` when a body write supplies a `baseRevision`
61
+ * that doesn't match the current body's content hash. Caller (autosave,
62
+ * MCP, agent, event processor) refetches and retries against the new
63
+ * revision.
64
+ */
65
+ export class StaleBaseRevisionError extends Error {
66
+ entityType;
67
+ entityId;
68
+ expected;
69
+ actual;
70
+ code = 'STALE_BASE_REVISION';
71
+ constructor(entityType, entityId, expected, actual) {
72
+ super(`Stale base_revision for ${entityType}/${entityId}: caller supplied ${expected.slice(0, 12)}…, ` +
73
+ `current is ${actual.slice(0, 12)}…. Refetch current content and retry.`);
74
+ this.entityType = entityType;
75
+ this.entityId = entityId;
76
+ this.expected = expected;
77
+ this.actual = actual;
78
+ this.name = 'StaleBaseRevisionError';
79
+ }
80
+ }
81
+ /**
82
+ * Thrown by `restoreFile` when the path the recreate would land at is
83
+ * currently occupied by a different entity. Carries the occupant's
84
+ * coordinates so callers (agent tool, REST endpoint) can surface a
85
+ * useful error to the user.
86
+ */
87
+ export class PathConflictError extends Error {
88
+ path;
89
+ occupiedBy;
90
+ code = 'PATH_CONFLICT';
91
+ constructor(path, occupiedBy) {
92
+ super(`Path ${path} is occupied by ${occupiedBy.entityType}/${occupiedBy.entityId}`);
93
+ this.path = path;
94
+ this.occupiedBy = occupiedBy;
95
+ this.name = 'PathConflictError';
96
+ }
97
+ }
15
98
  export class BaseGraphManager {
16
99
  repoPath;
17
100
  repoName;
18
101
  gitService;
19
102
  noGit;
20
103
  markdownService;
104
+ csvService;
21
105
  assetService;
106
+ /** Workspace-global ownership index for immutable-keyed assets. Injected by
107
+ * WorkspaceManager (one per workspace). Undefined in coordinator-less test
108
+ * / CLI graphs — those fall back to the legacy `{type}/{id}` key scheme. */
109
+ assetIndex;
22
110
  gitUser;
23
- csvService;
24
111
  vectorService;
25
112
  schemaRegistry;
113
+ workspaceRoot;
114
+ /** Cache for history-panel change summaries, keyed by `${relPath}@${hash}`.
115
+ * A commit's diff against its parent is immutable, so this never needs
116
+ * invalidation — entries only accumulate as history is viewed. */
117
+ historyChangeCache = new Map();
118
+ persistenceCallbacks;
119
+ bodyWriteCoordinator;
26
120
  constructor(config) {
27
121
  this.repoPath = config.repoPath;
28
122
  this.repoName = config.repoName;
@@ -30,6 +124,7 @@ export class BaseGraphManager {
30
124
  this.noGit = config.noGit ?? false;
31
125
  this.vectorService = config.vectorService;
32
126
  this.schemaRegistry = config.schemaRegistry;
127
+ this.workspaceRoot = config.workspaceRoot;
33
128
  // Initialize services
34
129
  if (!this.noGit) {
35
130
  this.gitService = new GitService(this.repoPath);
@@ -43,10 +138,107 @@ export class BaseGraphManager {
43
138
  // Ensure repo is initialized
44
139
  this.ensureRepo();
45
140
  }
46
- /** Expose git service for deferred commit operations (e.g. CommitScheduler). */
141
+ /**
142
+ * Friendly display label for an entity type — custom-type label when the
143
+ * workspace defines one, otherwise the title-cased key ("deal" → "Deal").
144
+ * Used to build human-readable commit messages.
145
+ */
146
+ typeLabel(type) {
147
+ return this.schemaRegistry?.getCustomTypeLabel(type) ?? titleCaseType(type);
148
+ }
149
+ /** Build an EntityRef for a commit message from a type, id, and optional title. */
150
+ commitRef(type, id, title) {
151
+ return { typeLabel: this.typeLabel(type), title: typeof title === 'string' ? title : undefined, id };
152
+ }
153
+ /**
154
+ * Build the friendly "Edited …" message for a deferred (batched) commit,
155
+ * given the "type/id" pairs accumulated by the SessionManager. Each title is
156
+ * resolved from the in-memory index at flush time (cheap lookup); an entity
157
+ * deleted before the flush falls back to its slug id.
158
+ */
159
+ buildEditedBatchMessage(entities) {
160
+ const refs = entities.map(({ type, id }) => {
161
+ let title;
162
+ try {
163
+ title = this.get(type, id).data?.name;
164
+ }
165
+ catch { /* gone → slug */ }
166
+ return this.commitRef(type, id, title);
167
+ });
168
+ return commitMsg.editedBatch(refs);
169
+ }
170
+ /**
171
+ * Change summary for one commit in a file's history — which frontmatter
172
+ * fields moved and whether the body changed — by diffing the file at `hash`
173
+ * against its true git parent (`hash^`). Used by the entity History panel.
174
+ *
175
+ * `relPath` is repo-relative. Null parent (root commit, or file absent at the
176
+ * parent) → `created`. Result is immutable per (path, hash), so it's cached.
177
+ * Returns null only when the file isn't present at `hash`.
178
+ */
179
+ getCommitChange(relPath, hash) {
180
+ if (!this.gitService)
181
+ return null;
182
+ const cacheKey = `${relPath}@${hash}`;
183
+ const cached = this.historyChangeCache.get(cacheKey);
184
+ if (cached)
185
+ return cached;
186
+ const current = this.gitService.getFileAtCommit(relPath, hash);
187
+ if (current === null)
188
+ return null;
189
+ // getFileAtCommit returns null when `hash^` has no commit (root) OR the
190
+ // file didn't exist there — both mean "created at this commit".
191
+ const parent = this.gitService.getFileAtCommit(relPath, `${hash}^`);
192
+ const change = computeChange(current, parent);
193
+ this.historyChangeCache.set(cacheKey, change);
194
+ return change;
195
+ }
196
+ /** Expose git service for deferred commit operations (SessionManager). */
47
197
  getGitService() {
48
198
  return this.gitService;
49
199
  }
200
+ /** Absolute repo path on disk. Used by callers that need to convert
201
+ * an entity's absolute path to a repo-relative one (e.g. for `git log`). */
202
+ getRepoPath() {
203
+ return this.repoPath;
204
+ }
205
+ /**
206
+ * Find an entity at a specific commit (existing or deleted) and
207
+ * return its repo-relative path + content. Public surface for the
208
+ * history tools — wraps the private `findEntityInCommit` walker.
209
+ * Returns null when no .md file at that commit matches the
210
+ * (entityType, entityId) pair.
211
+ */
212
+ getEntityAtCommit(entityType, entityId, commitHash) {
213
+ return this.findEntityInCommit(entityType, entityId, commitHash);
214
+ }
215
+ /** Workspace root passed in via GraphConfig. Used by the asset upload
216
+ * service to anchor PDF text sidecars at workspace level (so all repos
217
+ * share the same `assets-text/` tree, mirroring the shared
218
+ * `.studiograph/assets/` directory the local backend uses). */
219
+ getWorkspaceRoot() {
220
+ return this.workspaceRoot;
221
+ }
222
+ /**
223
+ * Install structural-mutation callbacks. Called once after construction by
224
+ * the server bootstrap (or test setup) so the Yjs persistence layer can
225
+ * react to rename/delete/changeType without route-layer code calling
226
+ * persistence APIs directly.
227
+ */
228
+ setPersistenceCallbacks(callbacks) {
229
+ this.persistenceCallbacks = callbacks;
230
+ }
231
+ /**
232
+ * Install the body-write coordinator. graph.update delegates the body-write
233
+ * critical section (lock, base-hash, atomic disk write, commit, broadcast,
234
+ * mdRevisionHash bookkeeping, assertWritable) to the coordinator. When
235
+ * unset (test fixtures without a Y.Doc layer), body writes fall back to a
236
+ * direct disk-write + commit path with no lock/broadcast — preserves the
237
+ * legacy "no callbacks" behavior some test setups rely on.
238
+ */
239
+ setBodyWriteCoordinator(coordinator) {
240
+ this.bodyWriteCoordinator = coordinator;
241
+ }
50
242
  /**
51
243
  * Ensure repository exists
52
244
  */
@@ -57,11 +249,140 @@ export class BaseGraphManager {
57
249
  // GitService automatically initializes in constructor
58
250
  }
59
251
  /**
60
- * Get entity file path: {repo}/{plural-type}/{entityId}.md
252
+ * Compute the on-disk path for a new entity.
253
+ *
254
+ * - When `folderPath` is provided, the entry lives at {repo}/{folderPath}/{id}.md
255
+ * regardless of type. This is the nested-folders layout.
256
+ * - Without `folderPath` (or empty string), the entry lands at the repo
257
+ * root: {repo}/{id}.md. The historical {repo}/{plural-type}/{id}.md
258
+ * fallback was confusing — entries hidden under a typed directory the
259
+ * list view never showed at repo root, contradicting the agent tool
260
+ * description that says "Omit to land at repo root."
261
+ *
262
+ * Note: this is for *new* paths only. Existing entries living under the
263
+ * legacy {plural-type}/ directories continue to be readable via
264
+ * findEntityFile() — only the create path moved.
265
+ */
266
+ getEntityPath(entityType, entityId, folderPath) {
267
+ const normalized = folderPath ? normalizeFolderPath(folderPath) : '';
268
+ if (normalized) {
269
+ return join(this.repoPath, normalized, `${entityId}.md`);
270
+ }
271
+ return join(this.repoPath, `${entityId}.md`);
272
+ }
273
+ // ────────────────────────────────────────────────────────────────────────────
274
+ // Recursive file walk + (type, id) → path index
275
+ //
276
+ // With nested folders, an entry can live at any depth under the repo root.
277
+ // We can't compute its path from (type, id) alone — we have to find it.
278
+ // The index caches that lookup; mutations invalidate it via invalidateIndex().
279
+ // ────────────────────────────────────────────────────────────────────────────
280
+ fileIndex;
281
+ /**
282
+ * Cache of the parsed `list()` view, keyed by `entityType ?? '*'`. Built
283
+ * lazily on first read; invalidated alongside `fileIndex` on every
284
+ * mutation. Without this, autosuggest walks the entire markdown tree
285
+ * and parses YAML frontmatter for every entry on every keystroke (5–7s
286
+ * on workspaces with a few thousand entries).
287
+ *
288
+ * Staleness contract: identical to `fileIndex` — single-process trust
289
+ * model. Git pulls and out-of-process file edits would equally miss
290
+ * the existing `fileIndex` cache; no regression there.
291
+ */
292
+ listCache;
293
+ /** Drop the cached file index so the next read rebuilds it. */
294
+ invalidateIndex() {
295
+ this.fileIndex = undefined;
296
+ this.listCache = undefined;
297
+ }
298
+ /**
299
+ * Walk every .md file under this repo, calling `visit` with the absolute
300
+ * path. Skips hidden directories (.git, .studiograph, etc.) and files that
301
+ * start with ".".
302
+ */
303
+ walkMarkdownFiles(visit) {
304
+ if (!existsSync(this.repoPath))
305
+ return;
306
+ const stack = [this.repoPath];
307
+ while (stack.length > 0) {
308
+ const dir = stack.pop();
309
+ let entries;
310
+ try {
311
+ entries = readdirSync(dir);
312
+ }
313
+ catch {
314
+ continue;
315
+ }
316
+ for (const entry of entries) {
317
+ if (entry.startsWith('.'))
318
+ continue;
319
+ if (WALK_IGNORE.has(entry))
320
+ continue;
321
+ const full = join(dir, entry);
322
+ let st;
323
+ try {
324
+ st = statSync(full);
325
+ }
326
+ catch {
327
+ continue;
328
+ }
329
+ if (st.isDirectory()) {
330
+ stack.push(full);
331
+ }
332
+ else if (st.isFile() && entry.endsWith('.md')) {
333
+ visit(full);
334
+ }
335
+ }
336
+ }
337
+ }
338
+ /**
339
+ * Build (or fetch the cached) "type/id" → absolute path index.
340
+ * Type comes from frontmatter `entity_type`; if missing, falls back to the
341
+ * top-level plural-type directory name (legacy layout). Files without either
342
+ * (e.g. README.md) are skipped — they aren't entities.
343
+ */
344
+ getFileIndex() {
345
+ if (this.fileIndex)
346
+ return this.fileIndex;
347
+ const index = new Map();
348
+ this.walkMarkdownFiles((filePath) => {
349
+ try {
350
+ const doc = this.markdownService.parseFile(filePath);
351
+ const fmType = doc.frontmatter.entity_type;
352
+ const inferred = this.inferLegacyType(filePath);
353
+ const type = fmType || inferred;
354
+ if (!type)
355
+ return; // README, CONTRIBUTING, etc.
356
+ const id = filePath.split('/').pop().replace(/\.md$/, '');
357
+ index.set(`${type}/${id}`, filePath);
358
+ }
359
+ catch { /* skip unparsable */ }
360
+ });
361
+ this.fileIndex = index;
362
+ return index;
363
+ }
364
+ /**
365
+ * Infer entity type from a file's location for legacy layouts where the
366
+ * file sits directly inside a top-level plural-type directory
367
+ * ({repo}/meetings/foo.md). Returns undefined for any other layout.
368
+ */
369
+ inferLegacyType(absPath) {
370
+ const rel = relative(this.repoPath, absPath);
371
+ const parts = rel.split('/');
372
+ if (parts.length !== 2)
373
+ return undefined; // not at depth 1
374
+ return PLURAL_TO_SINGULAR[parts[0]];
375
+ }
376
+ /**
377
+ * Resolve an entity's absolute path by (type, id). Returns undefined if not
378
+ * found. Uses the file index — the index keys by `${entityType}/${entityId}`
379
+ * which is type-aware, unlike the on-disk filename. (We previously had a
380
+ * legacy {plural-type}/{id}.md fast path here; once the create path moved
381
+ * to flat repo-root layout, that fast path could return a different
382
+ * type's file at the same id.)
61
383
  */
62
- getEntityPath(entityType, entityId) {
63
- const pluralDir = SINGULAR_TO_PLURAL[entityType] || `${entityType}s`;
64
- return join(this.repoPath, pluralDir, `${entityId}.md`);
384
+ findEntityFile(entityType, entityId) {
385
+ return this.getFileIndex().get(`${entityType}/${entityId}`);
65
386
  }
66
387
  /**
67
388
  * Validate entity data against schema
@@ -80,12 +401,19 @@ export class BaseGraphManager {
80
401
  try {
81
402
  // Add entity_type and preprocess YAML parsing artefacts:
82
403
  // - null → undefined (Zod .optional() doesn't accept null)
404
+ // - empty string on a date field → undefined (the date vocabulary's
405
+ // `date` / `start_date` are `.date()`-typed and reject ''; old create
406
+ // code seeded '' for absent optional strings, so existing entities
407
+ // carry empty date fields. Coercing to undefined keeps their next
408
+ // update from failing validation and self-heals the stored frontmatter.)
83
409
  // - Date objects → ISO date string (unquoted YAML dates parse as Date)
84
410
  // - bare string for array field → wrap in array (single-value YAML list)
85
411
  const raw = { ...data, entity_type: entityType };
86
412
  const dataWithType = Object.fromEntries(Object.entries(raw).map(([k, v]) => {
87
413
  if (v === null)
88
414
  return [k, undefined];
415
+ if ((k === 'date' || k === 'start_date') && v === '')
416
+ return [k, undefined];
89
417
  if (typeof v === 'object' && v !== null && Object.prototype.toString.call(v) === '[object Date]')
90
418
  return [k, v.toISOString().split('T')[0]];
91
419
  // Coerce scalar string to array for known array-typed fields
@@ -107,11 +435,19 @@ export class BaseGraphManager {
107
435
  return [k, v.toLowerCase()];
108
436
  return [k, v];
109
437
  }));
110
- return schema.parse(dataWithType);
438
+ const result = schema.parse(dataWithType);
439
+ // Cross-field task rule (start <= due). Skipped in lenient mode, which
440
+ // exists to keep already-stored entities discoverable — we don't want a
441
+ // historical bad-ordering to make an entity unreadable. See
442
+ // assertValidTaskDates for why this isn't a Zod refinement.
443
+ if (!lenient && entityType === 'task') {
444
+ assertValidTaskDates(result);
445
+ }
446
+ return result;
111
447
  }
112
448
  catch (error) {
113
449
  if (error instanceof z.ZodError) {
114
- const messages = error.errors.map(e => `${e.path.join('.')}: ${e.message}`);
450
+ const messages = error.issues.map(e => `${e.path.join('.')}: ${e.message}`);
115
451
  throw new Error(`Validation failed for ${entityType}:\n${messages.join('\n')}`);
116
452
  }
117
453
  throw error;
@@ -121,12 +457,18 @@ export class BaseGraphManager {
121
457
  * Create a new entity
122
458
  */
123
459
  async create(options) {
124
- const { entityType, entityId, data, content = '', commitMessage, skipCommit, user } = options;
460
+ const { entityType, entityId, data, content = '', commitMessage, skipCommit, folderPath, user } = options;
461
+ const normalizedFolder = folderPath ? validateFolderPath(folderPath) : '';
125
462
  // Prepopulate date from created_at if not provided
126
463
  if (!data.date && data.created_at) {
127
464
  data.date = String(data.created_at).split('T')[0];
128
465
  }
129
- // Add missing schema fields with empty defaults so the frontmatter is complete
466
+ // Add missing schema fields with empty defaults so the frontmatter is complete.
467
+ // Only string and array fields get populated — empty string isn't a valid
468
+ // member of number/boolean/enum/url types, and seeding them with '' caused
469
+ // optional numeric fields like `deal_value` to fail validation as
470
+ // "Expected number, received string". Anything that can't legitimately
471
+ // hold '' is left absent so the optional check passes.
130
472
  const schema = ENTITY_SCHEMAS[entityType];
131
473
  if (schema) {
132
474
  const shape = schema.shape;
@@ -139,28 +481,46 @@ export class BaseGraphManager {
139
481
  // Unwrap ZodOptional/ZodDefault to get the inner type
140
482
  const inner = field instanceof z.ZodOptional ? field._def.innerType
141
483
  : field instanceof z.ZodDefault ? field._def.innerType : field;
142
- if (inner instanceof z.ZodEnum) {
143
- // Don't populate enum fields with empty string — leave them unset
484
+ if (inner instanceof z.ZodArray) {
485
+ data[key] = [];
144
486
  continue;
145
487
  }
146
- const isArray = inner instanceof z.ZodArray;
147
- data[key] = isArray ? [] : '';
488
+ if (inner instanceof z.ZodString) {
489
+ // Skip format-constrained strings empty string fails url/date/
490
+ // datetime validation (e.g. `start_date` is now `.date()`-typed).
491
+ const FORMATS = new Set(['url', 'date', 'datetime']);
492
+ if (inner._def.checks?.some((c) => FORMATS.has(c.def?.format)))
493
+ continue;
494
+ data[key] = '';
495
+ continue;
496
+ }
497
+ // Numbers, booleans, enums, literals, unions: leave absent.
148
498
  }
149
499
  }
150
500
  }
151
501
  // Validate data
152
502
  const validatedData = this.validateEntity(entityType, data);
153
- // Check if entity already exists
154
- const entityPath = this.getEntityPath(entityType, entityId);
155
- if (existsSync(entityPath)) {
503
+ // Reject if (type, id) is already taken anywhere in the repo — IDs are
504
+ // unique within (repo, type) regardless of folder.
505
+ const existingPath = this.findEntityFile(entityType, entityId);
506
+ if (existingPath) {
156
507
  throw new Error(`Entity already exists: ${entityType}/${entityId}`);
157
508
  }
509
+ // Compute target path. With folderPath set, entries land at
510
+ // {repo}/{folderPath}/{id}.md (parents auto-created); without, fall back
511
+ // to legacy {plural-type}/{id}.md.
512
+ const entityPath = this.getEntityPath(entityType, entityId, normalizedFolder);
513
+ mkdirSync(dirname(entityPath), { recursive: true });
158
514
  // Write markdown file
159
515
  this.markdownService.writeFile(entityPath, {
160
516
  frontmatter: validatedData,
161
517
  content,
162
518
  wikilinks: this.markdownService.extractWikilinks(content, validatedData),
163
519
  });
520
+ // The folder now has an .md child, so any pre-existing .gitkeep is
521
+ // redundant. Drop it as part of the same commit so the repo stays tidy.
522
+ const removedMarker = this.removeFolderMarker(dirname(entityPath));
523
+ this.invalidateIndex();
164
524
  if (this.gitService && !skipCommit) {
165
525
  const transaction = this.gitService.createTransaction();
166
526
  transaction.add({
@@ -169,21 +529,42 @@ export class BaseGraphManager {
169
529
  entityId,
170
530
  filePath: entityPath,
171
531
  });
172
- const message = commitMessage || `Create ${entityType}: ${entityId}`;
532
+ if (removedMarker) {
533
+ transaction.add({
534
+ operation: 'delete',
535
+ entityType,
536
+ entityId,
537
+ filePath: removedMarker,
538
+ });
539
+ }
540
+ const message = commitMessage || commitMsg.created(this.commitRef(entityType, entityId, data?.name));
173
541
  await transaction.commit(user ?? this.gitUser, message);
174
542
  }
175
543
  // Return created entity
176
544
  const created = this.get(entityType, entityId);
177
- // Update vector index (fire-and-forget, non-fatal)
178
- this.vectorService?.upsert(this.repoName, created).catch(err => console.warn(`[vector] index failed for ${entityType}/${entityId}:`, err.message));
545
+ // Update vector index (fire-and-forget, non-fatal). Failures are
546
+ // tracked on VectorService for /api/health visibility the audit
547
+ // (2026-05) flagged that silent .catch(console.warn) made a steady
548
+ // index-failure leak undetectable until search results went stale.
549
+ if (this.vectorService) {
550
+ const v = this.vectorService;
551
+ v.beginOp();
552
+ v.upsert(this.repoName, created)
553
+ .then(() => v.recordSuccess())
554
+ .catch(err => {
555
+ v.recordFailure('upsert', `${entityType}/${entityId}`, err);
556
+ console.warn(`[vector] index failed for ${entityType}/${entityId}:`, err.message);
557
+ });
558
+ }
179
559
  return created;
180
560
  }
181
561
  /**
182
- * Get an entity by type and ID
562
+ * Get an entity by type and ID. Searches nested folders if the entry isn't
563
+ * at the legacy {plural-type}/{id}.md path.
183
564
  */
184
565
  get(entityType, entityId) {
185
- const entityPath = this.getEntityPath(entityType, entityId);
186
- if (!existsSync(entityPath)) {
566
+ const entityPath = this.findEntityFile(entityType, entityId);
567
+ if (!entityPath) {
187
568
  throw new Error(`Entity not found: ${entityType}/${entityId}`);
188
569
  }
189
570
  const document = this.markdownService.parseFile(entityPath);
@@ -205,115 +586,344 @@ export class BaseGraphManager {
205
586
  return {
206
587
  id: entityId,
207
588
  path: entityPath,
589
+ folder_path: folderPathFromFile(entityPath, this.repoPath),
208
590
  entityType,
209
591
  data: validatedData,
210
592
  document,
211
593
  };
212
594
  }
213
595
  /**
214
- * Get an entity by ID only (type resolved from frontmatter)
596
+ * The live editor body for an entity when a seeded Y.Doc is open, else
597
+ * null. `get` reads disk, which lags the live Y.Doc by the autosave
598
+ * debounce + commit — so a user's freshly-placed image/asset embed exists
599
+ * in the editor before it reaches disk. Callers that must reflect what the
600
+ * user is actually looking at (the get_entity surfaces, the whole-body
601
+ * asset-drop guard) prefer this and fall back to disk on null.
602
+ *
603
+ * Returns null when no coordinator is wired (CLI / test fixtures) — those
604
+ * setups have no live editor layer, so disk IS the authoritative body.
605
+ */
606
+ getLiveBody(entityType, entityId) {
607
+ if (!this.bodyWriteCoordinator?.peekLiveBody)
608
+ return null;
609
+ return this.bodyWriteCoordinator.peekLiveBody({
610
+ repo: this.repoName,
611
+ entityType,
612
+ entityId,
613
+ });
614
+ }
615
+ /**
616
+ * Get an entity by ID only (type resolved from the filesystem).
617
+ *
618
+ * Walks the file index to find any entry with this id, regardless of folder
619
+ * depth or type. Used by the agent's `get_entity` tool when the caller
620
+ * doesn't know the entity's type.
215
621
  */
216
622
  getById(entityId) {
217
- const entityPath = this.getEntityPath('', entityId);
218
- if (!existsSync(entityPath)) {
623
+ if (!existsSync(this.repoPath)) {
219
624
  throw new Error(`Entity not found: ${entityId}`);
220
625
  }
221
- const document = this.markdownService.parseFile(entityPath);
222
- const entityType = document.frontmatter.entity_type ?? this.repoName;
223
- let validatedData;
626
+ for (const [key] of this.getFileIndex()) {
627
+ const slash = key.indexOf('/');
628
+ if (slash === -1)
629
+ continue;
630
+ const id = key.slice(slash + 1);
631
+ if (id === entityId) {
632
+ const type = key.slice(0, slash);
633
+ return this.get(type, entityId);
634
+ }
635
+ }
636
+ throw new Error(`Entity not found: ${entityId}`);
637
+ }
638
+ /**
639
+ * Merge a caller's `data` onto a base frontmatter, always stamping
640
+ * `updated_at` / `updated_by`, then validate. Even content-only edits
641
+ * (editor autosave, where `data` is undefined) re-stamp the audit fields —
642
+ * otherwise the trail freezes at whatever the last data-change user was,
643
+ * which for scaffolded files is "studiograph".
644
+ *
645
+ * `baseFrontmatter` is the frontmatter to merge onto. The body-write path
646
+ * passes the CURRENT on-disk frontmatter (read inside the per-doc lock) so
647
+ * a concurrent metadata-only edit isn't clobbered; the frontmatter-only
648
+ * path passes `existing.data` (synchronous, no lock window).
649
+ *
650
+ * Validation uses lenient mode + a Date-coercion fallback when no new data
651
+ * is supplied, since the existing entity may have been stored without all
652
+ * required fields, and gray-matter would otherwise serialize Date objects
653
+ * as full ISO timestamps.
654
+ */
655
+ composeUpdatedFrontmatter(entityType, baseFrontmatter, data, user) {
656
+ const updater = (user ?? this.gitUser).id;
657
+ const now = new Date().toISOString();
658
+ const mergedData = data
659
+ ? { ...baseFrontmatter, ...data, entity_type: entityType, updated_at: now, updated_by: updater }
660
+ : { ...baseFrontmatter, updated_at: now, updated_by: updater };
661
+ if (data) {
662
+ return this.validateEntity(entityType, mergedData);
663
+ }
224
664
  try {
225
- validatedData = this.validateEntity(entityType, document.frontmatter);
665
+ return this.validateEntity(entityType, mergedData);
226
666
  }
227
667
  catch {
228
- validatedData = this.validateEntity(entityType, document.frontmatter, true);
668
+ try {
669
+ return this.validateEntity(entityType, mergedData, true);
670
+ }
671
+ catch {
672
+ const coerced = Object.fromEntries(Object.entries(mergedData).map(([k, v]) => {
673
+ if (v !== null && typeof v === 'object' && Object.prototype.toString.call(v) === '[object Date]') {
674
+ return [k, v.toISOString().split('T')[0]];
675
+ }
676
+ return [k, v];
677
+ }));
678
+ return coerced;
679
+ }
229
680
  }
230
- return {
231
- id: entityId,
232
- path: entityPath,
233
- entityType,
234
- data: validatedData,
235
- document,
236
- };
237
681
  }
238
682
  /**
239
683
  * Update an existing entity
240
684
  */
241
685
  async update(options) {
242
- const { entityType, entityId, data, content, commitMessage, skipCommit, user } = options;
686
+ const { entityType, entityId, data, content, commitMessage, skipCommit, user, source, baseRevision } = options;
687
+ // Hard contract: body writes must declare provenance. A missing source
688
+ // used to warn-and-default to 'server-write', but a warning leaves a
689
+ // bypass-shaped footpath for future callers. Every production caller
690
+ // (autosave PUT, event processor, entity-mutations helper) already
691
+ // declares it; throw so a new body-write surface can't silently drift
692
+ // back into ambiguity.
693
+ if (content !== undefined && source === undefined) {
694
+ throw new Error(`update() for ${entityType}/${entityId} passed content without a source. ` +
695
+ `Body writes must declare source: 'autosave' | 'server-write'.`);
696
+ }
243
697
  // Get existing entity
244
698
  const existing = this.get(entityType, entityId);
245
- // Merge data (if provided) and add update metadata
246
- const mergedData = data
247
- ? {
248
- ...existing.data,
249
- ...data,
250
- entity_type: entityType,
251
- updated_at: new Date().toISOString(),
252
- updated_by: (user ?? this.gitUser).id,
253
- }
254
- : existing.data;
255
- // Validate merged data — use lenient validation when no new data is provided,
256
- // since the existing entity may have been stored without all required fields.
257
- // Raw fallback coerces Date objects to YYYY-MM-DD so gray-matter doesn't
258
- // serialize them as full ISO timestamps.
259
- let validatedData;
260
- if (data) {
261
- validatedData = this.validateEntity(entityType, mergedData);
699
+ // Update in place write to wherever the entry already lives, not
700
+ // wherever a fresh getEntityPath() computation would put it. Otherwise
701
+ // an entry in a nested folder gets duplicated to the legacy plural-type
702
+ // dir on first update.
703
+ const entityPath = existing.path;
704
+ if (content !== undefined && this.bodyWriteCoordinator) {
705
+ // Body write: delegate the critical section to the coordinator.
706
+ // assertWritable, per-doc lock, base-hash precondition, atomic write,
707
+ // commit, dirty-recovery, and post-commit applyAgentContent all live
708
+ // there now.
709
+ //
710
+ // Frontmatter is merged + validated INSIDE the coordinator's per-doc
711
+ // lock (via prepareFrontmatter) against the CURRENT on-disk
712
+ // frontmatter not a snapshot read here, before the lock. Editor
713
+ // autosave sends `{ content }` only; if we merged against a pre-lock
714
+ // snapshot, a metadata-only edit that commits while this write waits
715
+ // for the lock would be silently clobbered by the stale snapshot.
716
+ const result = await this.bodyWriteCoordinator.writeBody({ repo: this.repoName, entityType, entityId, entityPath }, content, {
717
+ prepareFrontmatter: (currentDiskFrontmatter) => this.composeUpdatedFrontmatter(entityType, currentDiskFrontmatter, data, user),
718
+ baseHash: baseRevision,
719
+ // Guaranteed defined: the guard at the top throws when content is
720
+ // present without a source, and this branch requires content.
721
+ source: source,
722
+ actor: user ?? this.gitUser,
723
+ skipCommit,
724
+ // Default to the friendly "Edited …" subject so server-write body
725
+ // edits (MCP/agent) match create/update/etc. The skipCommit path
726
+ // ignores this and defers to the SessionManager's batch message.
727
+ commitMessage: commitMessage ??
728
+ commitMsg.edited(this.commitRef(entityType, entityId, data?.name ?? existing.data?.name)),
729
+ });
730
+ options.onBroadcast?.(result.broadcast);
262
731
  }
263
732
  else {
264
- try {
265
- validatedData = this.validateEntity(entityType, mergedData);
733
+ // Frontmatter-only write (content === undefined) OR a test fixture
734
+ // running without a coordinator. No concurrent-lock window here: the
735
+ // read-merge-write below is synchronous, so it can't interleave with
736
+ // the coordinator's (also synchronous) critical section.
737
+ const validatedData = this.composeUpdatedFrontmatter(entityType, existing.data, data, user);
738
+ const mergedContent = content !== undefined ? content : existing.document.content;
739
+ // baseRevision is meaningless without a body change; coordinator-less
740
+ // setups exist solely for tests that don't exercise the autosave 412
741
+ // contract.
742
+ if (baseRevision !== undefined && content !== undefined) {
743
+ const currentBody = this.markdownService.parseFile(entityPath).content;
744
+ const currentHash = sha256OfBody(currentBody);
745
+ if (currentHash !== baseRevision) {
746
+ throw new StaleBaseRevisionError(entityType, entityId, baseRevision, currentHash);
747
+ }
266
748
  }
267
- catch {
749
+ this.markdownService.writeFile(entityPath, {
750
+ frontmatter: validatedData,
751
+ content: mergedContent,
752
+ wikilinks: this.markdownService.extractWikilinks(mergedContent, validatedData),
753
+ });
754
+ if (this.gitService && !skipCommit) {
755
+ const transaction = this.gitService.createTransaction();
756
+ transaction.add({
757
+ operation: 'update',
758
+ entityType,
759
+ entityId,
760
+ filePath: entityPath,
761
+ });
762
+ const message = commitMessage || commitMsg.edited(this.commitRef(entityType, entityId, validatedData?.name));
268
763
  try {
269
- validatedData = this.validateEntity(entityType, mergedData, true);
764
+ await transaction.commit(user ?? this.gitUser, message);
270
765
  }
271
- catch {
272
- const coerced = Object.fromEntries(Object.entries(mergedData).map(([k, v]) => {
273
- if (v !== null && typeof v === 'object' && Object.prototype.toString.call(v) === '[object Date]') {
274
- return [k, v.toISOString().split('T')[0]];
275
- }
276
- return [k, v];
277
- }));
278
- validatedData = coerced;
766
+ catch (err) {
767
+ throw new CommitFailedDirtyError(entityType, entityId, entityPath, err);
279
768
  }
280
769
  }
281
770
  }
282
- // Merge content (if provided)
283
- const mergedContent = content !== undefined ? content : existing.document.content;
284
- // Write updated markdown file
285
- const entityPath = this.getEntityPath(entityType, entityId);
286
- this.markdownService.writeFile(entityPath, {
287
- frontmatter: validatedData,
288
- content: mergedContent,
289
- wikilinks: this.markdownService.extractWikilinks(mergedContent, validatedData),
290
- });
291
- if (this.gitService && !skipCommit) {
292
- const transaction = this.gitService.createTransaction();
293
- transaction.add({
294
- operation: 'update',
295
- entityType,
296
- entityId,
297
- filePath: entityPath,
298
- });
299
- const message = commitMessage || `Update ${entityType}: ${entityId}`;
300
- await transaction.commit(user ?? this.gitUser, message);
301
- }
771
+ // The on-disk frontmatter has changed, so the parsed-EntityFile cache
772
+ // that list() serves from is stale until invalidated. Without this, the
773
+ // next list_entities call returns pre-update frontmatter while
774
+ // get_entity (which reads fresh from disk) returns post-update — every
775
+ // other mutation in this file already invalidates after its write.
776
+ this.invalidateIndex();
302
777
  // Return updated entity
303
778
  const updated = this.get(entityType, entityId);
304
- // Update vector index (fire-and-forget, non-fatal)
305
- this.vectorService?.upsert(this.repoName, updated).catch(err => console.warn(`[vector] index failed for ${entityType}/${entityId}:`, err.message));
779
+ // Update vector index (fire-and-forget, non-fatal). Tracked on the
780
+ // service so /api/health surfaces a steady failure stream instead of
781
+ // burying it in stdout warnings.
782
+ if (this.vectorService) {
783
+ const v = this.vectorService;
784
+ v.beginOp();
785
+ v.upsert(this.repoName, updated)
786
+ .then(() => v.recordSuccess())
787
+ .catch(err => {
788
+ v.recordFailure('upsert', `${entityType}/${entityId}`, err);
789
+ console.warn(`[vector] index failed for ${entityType}/${entityId}:`, err.message);
790
+ });
791
+ }
306
792
  return updated;
307
793
  }
308
794
  /**
309
795
  * Delete an entity
310
796
  */
311
- async delete(entityType, entityId, commitMessage, user) {
797
+ /**
798
+ * Delete multiple entities in a single git transaction. Each entity's
799
+ * file is removed from disk; folder markers (.gitkeep) are added where
800
+ * the deletion left a folder empty. Then everything commits as one
801
+ * atomic operation.
802
+ *
803
+ * Compared to looping `delete()` per entity, this is a real performance
804
+ * primitive: one fork of git, one index update, one commit, one ref
805
+ * advance — instead of N. Bulk delete of a few hundred entities goes
806
+ * from minutes to seconds.
807
+ *
808
+ * Errors per entity (e.g. "not found") don't abort the batch — they're
809
+ * collected in `errors` and the rest still commit. Returns both the
810
+ * successfully-deleted set (for vector cleanup + WS broadcast) and the
811
+ * errors so the caller can surface partial failures.
812
+ */
813
+ async bulkDelete(specs, options) {
814
+ const deleted = [];
815
+ const errors = [];
816
+ const transaction = this.gitService?.createTransaction();
817
+ for (const { entityType, entityId } of specs) {
818
+ try {
819
+ const existing = this.get(entityType, entityId);
820
+ rmSync(existing.path, { force: true });
821
+ // Folder marker if the deletion emptied the folder — same lifecycle
822
+ // semantics as single delete().
823
+ const addedMarker = this.ensureFolderMarker(dirname(existing.path));
824
+ if (transaction) {
825
+ transaction.add({ operation: 'delete', entityType, entityId, filePath: existing.path });
826
+ if (addedMarker) {
827
+ transaction.add({ operation: 'create', entityType, entityId, filePath: addedMarker });
828
+ }
829
+ }
830
+ deleted.push({ entityType, entityId, path: existing.path, title: existing.data?.name });
831
+ }
832
+ catch (err) {
833
+ errors.push({
834
+ entityType,
835
+ entityId,
836
+ error: err instanceof Error ? err.message : String(err),
837
+ });
838
+ }
839
+ }
840
+ this.invalidateIndex();
841
+ if (transaction && deleted.length > 0) {
842
+ const message = options?.commitMessage
843
+ ?? commitMsg.deletedBatch(deleted.map(d => this.commitRef(d.entityType, d.entityId, d.title)));
844
+ await transaction.commit(options?.user ?? this.gitUser, message);
845
+ }
846
+ // Asset + vector cleanup for each successfully-deleted entity.
847
+ for (const { entityType, entityId } of deleted) {
848
+ // Immutable-keyed assets owned by this entity (same as single delete()).
849
+ await this.cleanupOwnedAssets(entityType, entityId);
850
+ // Legacy asset-type blob/sidecar (cutover).
851
+ if (entityType === 'asset' && this.assetService) {
852
+ this.assetService.deleteAll('asset', entityId).catch(err => console.warn(`[asset] deleteAll failed for asset/${entityId}: ${err?.message ?? err}`));
853
+ if (this.workspaceRoot) {
854
+ const sidecar = join(this.workspaceRoot, '.studiograph', 'assets-text', 'asset', `${entityId}.txt`);
855
+ try {
856
+ rmSync(sidecar, { force: true });
857
+ }
858
+ catch { /* best-effort */ }
859
+ }
860
+ }
861
+ }
862
+ // Vector index cleanup, fire-and-forget (matches single delete() behavior).
863
+ // Failures recorded on VectorService for /api/health visibility.
864
+ for (const { entityType, entityId } of deleted) {
865
+ if (!this.vectorService)
866
+ continue;
867
+ const v = this.vectorService;
868
+ v.beginOp();
869
+ v.delete(this.repoName, entityType, entityId)
870
+ .then(() => v.recordSuccess())
871
+ .catch(err => {
872
+ v.recordFailure('delete', `${entityType}/${entityId}`, err);
873
+ console.warn(`[vector] delete failed for ${entityType}/${entityId}:`, err.message);
874
+ });
875
+ }
876
+ return { deleted, errors };
877
+ }
878
+ /**
879
+ * Commit a set of already-on-disk file changes as a single git
880
+ * operation. Used by callers (e.g. the import route) that perform many
881
+ * `skipCommit: true` writes and want one transaction at the end instead
882
+ * of per-file commits.
883
+ *
884
+ * `entries` describes what each file represents so the transaction can
885
+ * be staged correctly. No-op when nothing is staged or git isn't
886
+ * available.
887
+ */
888
+ async commitPending(entries, options) {
889
+ if (!this.gitService)
890
+ return { committed: false };
891
+ if (entries.length === 0 && !options.extraPaths?.length)
892
+ return { committed: false };
893
+ const transaction = this.gitService.createTransaction();
894
+ for (const e of entries)
895
+ transaction.add(e);
896
+ // Auxiliary paths (folder-display sidecars from the import flow) are
897
+ // staged alongside the entity files so they land in the same commit.
898
+ // The transaction only uses `filePath` for staging — the other fields
899
+ // are metadata, so we fill them with sentinel values.
900
+ if (options.extraPaths?.length) {
901
+ for (const filePath of options.extraPaths) {
902
+ transaction.add({
903
+ operation: 'create',
904
+ entityType: '_sidecar',
905
+ entityId: '_sidecar',
906
+ filePath,
907
+ });
908
+ }
909
+ }
910
+ await transaction.commit(options.user ?? this.gitUser, options.commitMessage);
911
+ return { committed: true };
912
+ }
913
+ async delete(entityType, entityId, commitMessage, user, opts) {
312
914
  // Verify entity exists
313
915
  const existing = this.get(entityType, entityId);
314
916
  // Delete the flat entity file
315
- const { rmSync } = await import('fs');
316
917
  rmSync(existing.path, { force: true });
918
+ // Asset cleanup. Skipped when keepAssets is set — cross-repo MOVE re-points
919
+ // ownership to the target repo, so the source-side delete must NOT destroy
920
+ // the blobs that now belong to the moved entity.
921
+ // Folder lifecycle: deleting an entry never deletes its containing folder.
922
+ // If this was the last .md in the folder, write a .gitkeep so it stays
923
+ // visible to listFolders() (and therefore to the sidebar). A user who
924
+ // wants the folder gone calls deleteFolder() explicitly.
925
+ const addedMarker = this.ensureFolderMarker(dirname(existing.path));
926
+ this.invalidateIndex();
317
927
  if (this.gitService) {
318
928
  const transaction = this.gitService.createTransaction();
319
929
  transaction.add({
@@ -322,11 +932,62 @@ export class BaseGraphManager {
322
932
  entityId,
323
933
  filePath: existing.path,
324
934
  });
325
- const message = commitMessage || `Delete ${entityType}: ${entityId}`;
935
+ if (addedMarker) {
936
+ transaction.add({
937
+ operation: 'create',
938
+ entityType,
939
+ entityId,
940
+ filePath: addedMarker,
941
+ });
942
+ }
943
+ const message = commitMessage || commitMsg.deleted(this.commitRef(entityType, entityId, existing.data?.name));
326
944
  await transaction.commit(user ?? this.gitUser, message);
327
945
  }
328
- // Remove from vector index (fire-and-forget, non-fatal)
329
- this.vectorService?.delete(this.repoName, entityType, entityId).catch(err => console.warn(`[vector] delete failed for ${entityType}/${entityId}:`, err.message));
946
+ // Asset cleanup AFTER the markdown delete commits — a commit failure
947
+ // (CommitFailedDirtyError) leaves the entry recoverable, so we must not
948
+ // have already destroyed its binaries. Matches bulkDelete's ordering.
949
+ // Skipped when keepAssets is set (cross-repo move re-points ownership).
950
+ if (!opts?.keepAssets) {
951
+ // Legacy asset-type blob + PDF sidecar (cutover; pre-migration assets,
952
+ // keyed asset/{id}/...). Fire-and-forget: markdown is the system of record.
953
+ if (entityType === 'asset' && this.assetService) {
954
+ this.assetService.deleteAll('asset', entityId).catch(err => console.warn(`[asset] deleteAll failed for asset/${entityId}: ${err?.message ?? err}`));
955
+ if (this.workspaceRoot) {
956
+ const sidecarPath = join(this.workspaceRoot, '.studiograph', 'assets-text', 'asset', `${entityId}.txt`);
957
+ try {
958
+ rmSync(sidecarPath, { force: true });
959
+ }
960
+ catch { /* sidecar cleanup is best-effort */ }
961
+ }
962
+ }
963
+ // Immutable-keyed assets owned by this entity (any type) via the index.
964
+ await this.cleanupOwnedAssets(entityType, entityId);
965
+ }
966
+ // Remove from vector index (fire-and-forget, non-fatal). Tracked on
967
+ // the service so a leaking error stream is visible at /api/health.
968
+ if (this.vectorService) {
969
+ const v = this.vectorService;
970
+ v.beginOp();
971
+ v.delete(this.repoName, entityType, entityId)
972
+ .then(() => v.recordSuccess())
973
+ .catch(err => {
974
+ v.recordFailure('delete', `${entityType}/${entityId}`, err);
975
+ console.warn(`[vector] delete failed for ${entityType}/${entityId}:`, err.message);
976
+ });
977
+ }
978
+ // Notify Yjs persistence. Awaited (not fire-and-forget) because docName
979
+ // reuse is a real scenario: delete X then create a new entity at X. If the
980
+ // delete callback fired async, it could race the new entity's snapshot and
981
+ // unlink it after creation. Failures are logged but don't throw — orphaned
982
+ // snapshot files are a leak, not a correctness issue.
983
+ if (this.persistenceCallbacks?.onDelete) {
984
+ try {
985
+ await this.persistenceCallbacks.onDelete(this.repoName, entityType, entityId);
986
+ }
987
+ catch (err) {
988
+ console.warn(`[graph] onDelete callback failed for ${entityType}/${entityId}:`, err);
989
+ }
990
+ }
330
991
  }
331
992
  /**
332
993
  * Rename an entity (change its ID). Preserves all data, content, and source_ref.
@@ -334,7 +995,8 @@ export class BaseGraphManager {
334
995
  */
335
996
  async rename(entityType, oldId, newId, commitMessage, user) {
336
997
  const existing = this.get(entityType, oldId);
337
- const newPath = this.getEntityPath(entityType, newId);
998
+ // Rename keeps the entry in its current folder — only the filename changes.
999
+ const newPath = join(dirname(existing.path), `${newId}.md`);
338
1000
  if (existsSync(newPath)) {
339
1001
  throw new Error(`Entity ${entityType}/${newId} already exists`);
340
1002
  }
@@ -348,27 +1010,77 @@ export class BaseGraphManager {
348
1010
  // Remove old file
349
1011
  const { rmSync } = await import('fs');
350
1012
  rmSync(existing.path, { force: true });
351
- // Git: stage the rename
1013
+ this.invalidateIndex();
352
1014
  if (this.gitService) {
353
- const { spawnSync } = await import('child_process');
354
- spawnSync('git', ['add', newPath], { cwd: this.repoPath, stdio: 'ignore' });
355
- spawnSync('git', ['add', existing.path], { cwd: this.repoPath, stdio: 'ignore' });
356
- const transaction = this.gitService.createTransaction();
357
- const message = commitMessage || `Rename ${entityType}: ${oldId} ${newId}`;
358
- await transaction.commit(user ?? this.gitUser, message);
1015
+ const message = commitMessage || commitMsg.renamed(this.typeLabel(entityType), oldId, newId);
1016
+ this.gitService.commitFiles([existing.path, newPath], message, user ?? this.gitUser);
1017
+ }
1018
+ // Re-point asset ownership to the new id. Keys are immutable, so no blob
1019
+ // moves and the embed URLs in the body keep resolving — this just keeps
1020
+ // listByOwner/delete/transfer correct after the rename.
1021
+ this.reassignOwnedAssets(entityType, oldId, { ownerId: newId });
1022
+ // Asset-type entities also carry a PDF text sidecar keyed by entity id;
1023
+ // move it so re-index/cleanup still find it.
1024
+ if (entityType === 'asset' && this.workspaceRoot) {
1025
+ const dir = join(this.workspaceRoot, '.studiograph', 'assets-text', 'asset');
1026
+ const oldSidecar = join(dir, `${oldId}.txt`);
1027
+ const newSidecar = join(dir, `${newId}.txt`);
1028
+ try {
1029
+ if (existsSync(oldSidecar))
1030
+ renameSync(oldSidecar, newSidecar);
1031
+ }
1032
+ catch { /* best-effort */ }
1033
+ }
1034
+ // Update vector index — failure tracked on the service so a steady
1035
+ // leak surfaces at /api/health rather than getting swallowed entirely.
1036
+ if (this.vectorService) {
1037
+ const v = this.vectorService;
1038
+ v.beginOp();
1039
+ v.delete(this.repoName, entityType, oldId)
1040
+ .then(() => v.recordSuccess())
1041
+ .catch(err => v.recordFailure('delete', `${entityType}/${oldId}`, err));
1042
+ }
1043
+ // Notify Yjs persistence to rename the snapshot file alongside. Awaited
1044
+ // because rename oldId→newId followed by creating a new entity at oldId
1045
+ // would otherwise let the async rename clobber the new oldId snapshot.
1046
+ if (this.persistenceCallbacks?.onRename) {
1047
+ try {
1048
+ await this.persistenceCallbacks.onRename(this.repoName, entityType, oldId, newId);
1049
+ }
1050
+ catch (err) {
1051
+ console.warn(`[graph] onRename callback failed for ${entityType}/${oldId}→${newId}:`, err);
1052
+ }
359
1053
  }
360
- // Update vector index
361
- this.vectorService?.delete(this.repoName, entityType, oldId).catch(() => { });
362
1054
  return this.get(entityType, newId);
363
1055
  }
364
1056
  /**
365
- * Change an entity's type. Moves the file from oldType/ to newType/ directory.
366
- * Preserves all data and content. Updates entity_type in frontmatter.
1057
+ * Change an entity's type. Updates frontmatter only the file stays in
1058
+ * its current folder. In the type-agnostic folder model, type is a
1059
+ * frontmatter property independent of location. (Legacy behavior moved
1060
+ * the file between {plural-type}/ dirs; that's no longer correct because
1061
+ * those dirs are now ordinary user-visible folders, not type markers.)
1062
+ *
1063
+ * If the user wants the file to also move (e.g. out of a "meetings/"
1064
+ * folder when changed to a note), they invoke move() separately.
367
1065
  */
368
1066
  async changeType(entityId, fromType, toType, commitMessage, user) {
1067
+ // Asset entities are bound to a binary in the storage backend plus
1068
+ // (for PDFs) a text sidecar. Changing TO asset would leave the entity
1069
+ // without an asset_url / asset_kind / blob; changing FROM asset would
1070
+ // orphan the binary and sidecar. Both directions must be a
1071
+ // delete-and-recreate, not a type swap.
1072
+ if (toType === 'asset' || fromType === 'asset') {
1073
+ const direction = toType === 'asset' ? 'to' : 'from';
1074
+ throw new Error(`Cannot change_type ${direction} 'asset' — assets are bound to a binary in storage. ` +
1075
+ (toType === 'asset'
1076
+ ? 'Delete this entry and re-upload the file via the asset upload route to convert it to an asset.'
1077
+ : 'Delete this asset entry (which removes the binary) and create a fresh entry of the target type.'));
1078
+ }
369
1079
  const existing = this.get(fromType, entityId);
370
- const newPath = this.getEntityPath(toType, entityId);
371
- if (existsSync(newPath)) {
1080
+ // (type, id) uniqueness check — if an entry with the new type already
1081
+ // exists with this id, refuse rather than silently colliding in the index.
1082
+ const collision = this.findEntityFile(toType, entityId);
1083
+ if (collision && collision !== existing.path) {
372
1084
  throw new Error(`Entity ${toType}/${entityId} already exists`);
373
1085
  }
374
1086
  // Read current content
@@ -408,144 +1120,445 @@ export class BaseGraphManager {
408
1120
  }
409
1121
  }
410
1122
  }
411
- // Ensure target directory exists and write new file
412
- mkdirSync(dirname(newPath), { recursive: true });
413
- this.markdownService.writeFile(newPath, doc);
414
- // Remove old file
415
- const { rmSync } = await import('fs');
416
- rmSync(existing.path, { force: true });
417
- // Git: stage the move and commit directly
1123
+ // Write back to the same path file stays in its current folder.
1124
+ this.markdownService.writeFile(existing.path, doc);
1125
+ this.invalidateIndex();
418
1126
  if (this.gitService) {
419
- const { spawnSync } = await import('child_process');
420
- const gitUser = user ?? this.gitUser;
421
- const message = commitMessage || `Change type: ${entityId} (${fromType} → ${toType})`;
422
- spawnSync('git', ['add', newPath], { cwd: this.repoPath, stdio: 'ignore' });
423
- spawnSync('git', ['add', existing.path], { cwd: this.repoPath, stdio: 'ignore' });
424
- spawnSync('git', [
425
- '-c', `user.name=${gitUser.name}`,
426
- '-c', `user.email=${gitUser.email}`,
427
- 'commit', '-m', message,
428
- ], { cwd: this.repoPath, stdio: 'ignore' });
429
- }
430
- // Update vector index: remove old entry, re-index at new type
1127
+ const message = commitMessage || commitMsg.changedType(this.commitRef(fromType, entityId, existing.data?.name), this.typeLabel(fromType), this.typeLabel(toType));
1128
+ this.gitService.commitFiles([existing.path], message, user ?? this.gitUser);
1129
+ }
1130
+ // Re-point asset ownership to the new type (keys immutable; no blob moves).
1131
+ // changeType refuses to/from 'asset', so no PDF sidecar move is needed here.
1132
+ this.reassignOwnedAssets(fromType, entityId, { ownerType: toType });
1133
+ // Update vector index: remove old entry, re-index at new type. Both ops
1134
+ // tracked on the service so /api/health surfaces a leaking failure
1135
+ // stream — previous .catch(() => {}) form was completely silent.
431
1136
  const updated = this.get(toType, entityId);
432
- this.vectorService?.delete(this.repoName, fromType, entityId).catch(() => { });
433
- this.vectorService?.upsert(this.repoName, updated).catch(() => { });
1137
+ if (this.vectorService) {
1138
+ const v = this.vectorService;
1139
+ v.beginOp();
1140
+ v.delete(this.repoName, fromType, entityId)
1141
+ .then(() => v.recordSuccess())
1142
+ .catch(err => v.recordFailure('delete', `${fromType}/${entityId}`, err));
1143
+ v.beginOp();
1144
+ v.upsert(this.repoName, updated)
1145
+ .then(() => v.recordSuccess())
1146
+ .catch(err => v.recordFailure('upsert', `${toType}/${entityId}`, err));
1147
+ }
1148
+ // Notify Yjs persistence to move the snapshot from the old type's path to
1149
+ // the new one (the docName changes, so the snapshot path changes). Awaited
1150
+ // for the same reason as rename — preserves ordering when types are
1151
+ // changed back and forth quickly.
1152
+ if (this.persistenceCallbacks?.onChangeType) {
1153
+ try {
1154
+ await this.persistenceCallbacks.onChangeType(this.repoName, entityId, fromType, toType);
1155
+ }
1156
+ catch (err) {
1157
+ console.warn(`[graph] onChangeType callback failed for ${entityId} (${fromType}→${toType}):`, err);
1158
+ }
1159
+ }
434
1160
  return updated;
435
1161
  }
436
1162
  /**
437
- * List entities of a given type
1163
+ * Move an entry to a different folder within the same repo. The filename
1164
+ * (and therefore the entity id) and type are unchanged. Auto-creates parent
1165
+ * folders. Refuses if the target path already has an entry with this id —
1166
+ * pass `overwrite: true` to replace it, but only when the occupant is
1167
+ * itself a parseable entity (never silently clobber arbitrary files like
1168
+ * README.md or .gitkeep).
438
1169
  */
439
- list(entityType, limit) {
440
- if (!existsSync(this.repoPath))
441
- return [];
442
- const results = [];
443
- // Lenient validation for list(): try strict schema first, fall back to
444
- // EntityBaseSchema, then raw data so app-defined types are still discovered.
445
- const validateLenient = (type, data) => {
446
- try {
447
- return this.validateEntity(type, data);
1170
+ async move(entityType, entityId, targetFolderPath, commitMessage, user, overwrite) {
1171
+ const existing = this.get(entityType, entityId);
1172
+ const normalized = normalizeFolderPath(targetFolderPath);
1173
+ const newPath = join(this.repoPath, normalized, `${entityId}.md`);
1174
+ if (newPath === existing.path)
1175
+ return existing; // no-op
1176
+ if (existsSync(newPath)) {
1177
+ if (!overwrite) {
1178
+ throw new Error(`Path already occupied: ${normalized}/${entityId}.md`);
448
1179
  }
449
- catch {
450
- try {
451
- return this.validateEntity(type, { ...data, entity_type: type }, true);
1180
+ // Safety guard: only overwrite when the destination is itself an
1181
+ // entity .md (parseable frontmatter or legacy plural-type dir). Refuse
1182
+ // to clobber README.md, CHANGELOG.md, .gitkeep, or anything else that
1183
+ // isn't a tracked entry — that would be data loss the caller didn't
1184
+ // ask for.
1185
+ const occupant = this.identifyEntityAtPath(newPath);
1186
+ if (!occupant) {
1187
+ throw new Error(`Refusing to overwrite non-entity file at ${normalized}/${entityId}.md — ` +
1188
+ `destination must be a parseable entity for overwrite to be safe.`);
1189
+ }
1190
+ }
1191
+ mkdirSync(dirname(newPath), { recursive: true });
1192
+ const { renameSync } = await import('fs');
1193
+ // On POSIX, renameSync atomically replaces the destination if it exists.
1194
+ // The overwrite gate above ensures we only reach this point when the
1195
+ // occupant is a tracked entity (or the path is empty).
1196
+ renameSync(existing.path, newPath);
1197
+ // Folder lifecycle: source stays addressable via .gitkeep if it lost its
1198
+ // last .md; destination drops any redundant .gitkeep now that it has one.
1199
+ const filesToCommit = [existing.path, newPath];
1200
+ const addedSourceMarker = this.ensureFolderMarker(dirname(existing.path));
1201
+ if (addedSourceMarker)
1202
+ filesToCommit.push(addedSourceMarker);
1203
+ const removedDestMarker = this.removeFolderMarker(dirname(newPath));
1204
+ if (removedDestMarker)
1205
+ filesToCommit.push(removedDestMarker);
1206
+ this.invalidateIndex();
1207
+ if (this.gitService) {
1208
+ const message = commitMessage || commitMsg.movedTo(this.commitRef(entityType, entityId, existing.data?.name), normalized || '(root)');
1209
+ this.gitService.commitFiles(filesToCommit, message, user ?? this.gitUser);
1210
+ }
1211
+ return this.get(entityType, entityId);
1212
+ }
1213
+ /**
1214
+ * Restore an entity to its content at a specific commit. Single-entity
1215
+ * scope only — multi-entity rollback (e.g. "undo this commit's effects
1216
+ * across all touched files") is a separate, more cautious flow that
1217
+ * isn't built yet.
1218
+ *
1219
+ * Two paths through the method:
1220
+ * - **Overwrite**: the entity exists today at path `P`. Read content
1221
+ * from `git show <hash>:<P>` and write it back to `P`.
1222
+ * - **Recreate**: the entity is gone today. Walk the historical
1223
+ * commit's tree to find the .md file whose basename matches
1224
+ * `entityId` AND whose frontmatter `entity_type` matches
1225
+ * `entityType`. That path is the restore target. The walk only
1226
+ * fires when the entity is truly absent — overwrites stay cheap.
1227
+ *
1228
+ * Throws a `PathConflictError` if the recreate target path is now
1229
+ * occupied by a DIFFERENT entity (different type+id) — recreating
1230
+ * over it would silently overwrite unrelated work. Caller surfaces
1231
+ * the conflict to the user; resolution is "move or delete the
1232
+ * occupant first."
1233
+ */
1234
+ async restoreFile(entityType, entityId, commitHash, user) {
1235
+ if (!this.gitService) {
1236
+ throw new Error('Cannot restore — git service is not available for this repo');
1237
+ }
1238
+ const existingPath = this.findEntityFile(entityType, entityId);
1239
+ let targetPath;
1240
+ let action;
1241
+ let content;
1242
+ if (existingPath) {
1243
+ // Overwrite path. Read content at the historical commit using the
1244
+ // CURRENT path (git tracks files by content + path; if the entity
1245
+ // was renamed or moved across the file's lifetime, --follow would
1246
+ // be needed — out of scope for v1, the user can resolve by
1247
+ // restoring at a commit where the path matched).
1248
+ const relPath = relative(this.repoPath, existingPath);
1249
+ content = this.gitService.getFileAtCommit(relPath, commitHash);
1250
+ if (content === null) {
1251
+ throw new Error(`File ${relPath} did not exist at commit ${commitHash}`);
1252
+ }
1253
+ // Guard: if the entry was change_type'd after this commit, the file path
1254
+ // is unchanged but the historical frontmatter carries the OLD
1255
+ // entity_type. Writing it back would silently revert the type, bypassing
1256
+ // change_type's collision checks, asset re-ownership, vector reindex, and
1257
+ // persistence callbacks — and the route would still broadcast an update
1258
+ // for the current type. Refuse rather than corrupt.
1259
+ // Read entity_type straight from the raw frontmatter block (no shared
1260
+ // markdown-parse layer) so this reflects exactly what's on disk at the
1261
+ // historical commit.
1262
+ const fmBlock = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
1263
+ const historicalType = fmBlock?.[1]
1264
+ .match(/^entity_type:\s*["']?([^"'\r\n]+)["']?\s*$/m)?.[1]
1265
+ ?.trim();
1266
+ if (historicalType && historicalType !== entityType) {
1267
+ throw new Error(`Cannot restore: the entry was type '${historicalType}' at that commit but is '${entityType}' now. ` +
1268
+ `Restoring would change its type without the safeguards of change_type. ` +
1269
+ `Convert it explicitly with change_type if that's intended.`);
1270
+ }
1271
+ targetPath = existingPath;
1272
+ action = 'overwrite';
1273
+ }
1274
+ else {
1275
+ // Recreate path. Walk the historical commit's tree to find the
1276
+ // file whose basename matches entityId and frontmatter type
1277
+ // matches entityType. This is the only place we do a full
1278
+ // tree-walk — it only runs when the entity is gone today.
1279
+ const found = this.findEntityInCommit(entityType, entityId, commitHash);
1280
+ if (!found) {
1281
+ throw new Error(`Entity ${entityType}/${entityId} not found at commit ${commitHash}`);
1282
+ }
1283
+ targetPath = join(this.repoPath, found.path);
1284
+ content = found.content;
1285
+ action = 'recreate';
1286
+ // Path-conflict check: nothing else may occupy targetPath today.
1287
+ if (existsSync(targetPath)) {
1288
+ const occupant = this.identifyEntityAtPath(targetPath);
1289
+ if (!occupant) {
1290
+ // A non-entity file (README, stray .md, .gitkeep) now sits at the
1291
+ // restore path. identifyEntityAtPath returning null previously fell
1292
+ // through and writeFileSync clobbered it — silent data loss. Treat
1293
+ // any unidentifiable occupant as a conflict.
1294
+ throw new PathConflictError(found.path, {
1295
+ entityType: 'file',
1296
+ entityId: relative(this.repoPath, targetPath),
1297
+ });
452
1298
  }
453
- catch {
454
- return data;
1299
+ if (occupant.entityType !== entityType || occupant.entityId !== entityId) {
1300
+ throw new PathConflictError(found.path, occupant);
455
1301
  }
456
1302
  }
457
- };
458
- const seenIds = new Set();
459
- // Process a flat .md file as an entity
460
- const tryAddFile = (filePath, id, inferredType) => {
461
- if (seenIds.has(id))
462
- return;
463
- try {
464
- const doc = this.markdownService.parseFile(filePath);
465
- const fmType = doc.frontmatter.entity_type;
466
- const resolvedType = entityType ?? fmType ?? inferredType ?? this.repoName;
467
- if (entityType && fmType && fmType !== entityType)
468
- return;
469
- const validatedData = validateLenient(resolvedType, doc.frontmatter);
470
- seenIds.add(id);
471
- results.push({
472
- id,
473
- path: filePath,
474
- entityType: resolvedType,
475
- data: validatedData,
476
- document: doc,
477
- });
478
- }
479
- catch { /* skip invalid */ }
480
- };
481
- // Scan plural type subfolders for flat .md files
482
- for (const entry of readdirSync(this.repoPath)) {
483
- if (entry.startsWith('.'))
1303
+ }
1304
+ mkdirSync(dirname(targetPath), { recursive: true });
1305
+ writeFileSync(targetPath, content);
1306
+ // Folder lifecycle: if recreating dropped a file into a directory
1307
+ // that had been emptied (and now carries .gitkeep), drop the marker
1308
+ // so the directory exists via the .md file alone.
1309
+ const filesToCommit = [targetPath];
1310
+ if (action === 'recreate') {
1311
+ const removedDestMarker = this.removeFolderMarker(dirname(targetPath));
1312
+ if (removedDestMarker)
1313
+ filesToCommit.push(removedDestMarker);
1314
+ }
1315
+ this.invalidateIndex();
1316
+ const shortHash = commitHash.length > 8 ? commitHash.slice(0, 8) : commitHash;
1317
+ const restoredName = content ? this.markdownService.parseContent(content).frontmatter?.name : undefined;
1318
+ const message = commitMsg.restored(this.commitRef(entityType, entityId, restoredName), shortHash);
1319
+ this.gitService.commitFiles(filesToCommit, message, user ?? this.gitUser);
1320
+ return { action, path: targetPath };
1321
+ }
1322
+ /**
1323
+ * Walk a commit's tree looking for a .md file whose basename matches
1324
+ * `entityId` AND whose frontmatter `entity_type` matches `entityType`.
1325
+ * Returns repo-relative path + content when found, otherwise null.
1326
+ * Used by `restoreFile` for the recreate-from-deletion case.
1327
+ */
1328
+ findEntityInCommit(entityType, entityId, commitHash) {
1329
+ if (!this.gitService)
1330
+ return null;
1331
+ const filenameLower = `${entityId}.md`.toLowerCase();
1332
+ const tree = this.gitService.listFilesAtCommit(commitHash);
1333
+ for (const path of tree) {
1334
+ if (!path.toLowerCase().endsWith(filenameLower))
1335
+ continue;
1336
+ // basename match — read content and check frontmatter type.
1337
+ const content = this.gitService.getFileAtCommit(path, commitHash);
1338
+ if (content === null)
484
1339
  continue;
485
- const fullPath = join(this.repoPath, entry);
486
1340
  try {
487
- if (!statSync(fullPath).isDirectory())
488
- continue;
1341
+ // Cache-free parse: this is historical content, and the shared
1342
+ // gray-matter layer returned a stale entity_type for past-commit
1343
+ // content (see parseFrontmatterCacheFree), which would mis-match here.
1344
+ const fmType = parseFrontmatterCacheFree(content).entity_type;
1345
+ const inferred = this.inferLegacyType(join(this.repoPath, path));
1346
+ const type = fmType || inferred;
1347
+ if (type === entityType)
1348
+ return { path, content };
489
1349
  }
490
1350
  catch {
491
- continue;
1351
+ // unparsable file — skip
492
1352
  }
493
- const singularType = PLURAL_TO_SINGULAR[entry];
494
- if (!singularType)
495
- continue; // skip unknown directories
496
- try {
497
- for (const file of readdirSync(fullPath)) {
498
- if (file.startsWith('.') || !file.endsWith('.md'))
499
- continue;
500
- const filePath = join(fullPath, file);
501
- try {
502
- if (!statSync(filePath).isFile())
503
- continue;
504
- }
505
- catch {
506
- continue;
507
- }
508
- const id = file.replace(/\.md$/, '');
509
- tryAddFile(filePath, id, singularType);
510
- }
511
- }
512
- catch { /* skip unreadable */ }
513
1353
  }
514
- // Also discover flat .md files at repo root (README, etc.)
515
- for (const entry of readdirSync(this.repoPath)) {
516
- if (entry.startsWith('.'))
517
- continue;
518
- if (!entry.endsWith('.md'))
1354
+ return null;
1355
+ }
1356
+ /**
1357
+ * Identify the entity (if any) currently occupying `absPath`. Returns
1358
+ * null when the path holds something that isn't an entity (README, etc).
1359
+ * Mirrors the inference rules used in `getFileIndex`.
1360
+ */
1361
+ identifyEntityAtPath(absPath) {
1362
+ try {
1363
+ const doc = this.markdownService.parseFile(absPath);
1364
+ const fmType = doc.frontmatter.entity_type;
1365
+ const inferred = this.inferLegacyType(absPath);
1366
+ const type = fmType || inferred;
1367
+ if (!type)
1368
+ return null;
1369
+ const id = absPath.split('/').pop().replace(/\.md$/, '');
1370
+ return { entityType: type, entityId: id };
1371
+ }
1372
+ catch {
1373
+ return null;
1374
+ }
1375
+ }
1376
+ /**
1377
+ * Enumerate the distinct entity types present in this repo. Used by
1378
+ * /api/repos to let the sidebar render the Views list without a full
1379
+ * entity fetch per folder.
1380
+ *
1381
+ * Derived from the file index (which reads frontmatter), so it works for
1382
+ * entries in any folder — not just the legacy {plural-type}/ dirs.
1383
+ */
1384
+ listEntityTypes() {
1385
+ if (!existsSync(this.repoPath))
1386
+ return [];
1387
+ const types = new Set();
1388
+ for (const key of this.getFileIndex().keys()) {
1389
+ const slash = key.indexOf('/');
1390
+ if (slash > 0)
1391
+ types.add(key.slice(0, slash));
1392
+ }
1393
+ return [...types].sort();
1394
+ }
1395
+ /**
1396
+ * Folder lifecycle: a directory is treated as a first-class persistent
1397
+ * object. It exists from the moment it's created (createFolder, the destination
1398
+ * of a moved entry, etc.) until it's explicitly removed via a folder-level
1399
+ * operation (deleteFolder, renameFolder, moveFolderToRepo). Entry-level
1400
+ * operations (create, delete, move, transfer) never decide whether a folder
1401
+ * lives or dies.
1402
+ *
1403
+ * `listFolders()` requires either a .md child or a .gitkeep marker for a
1404
+ * directory to surface in the API, so the two helpers below are the single
1405
+ * place that adds and removes the marker:
1406
+ *
1407
+ * - `ensureFolderMarker` — call after an op that may have left `dir`
1408
+ * without .md children. Writes `.gitkeep` and returns its path so the
1409
+ * caller can stage it in the same commit.
1410
+ * - `removeFolderMarker` — call after an op that placed an .md into `dir`.
1411
+ * Drops a now-redundant `.gitkeep` so the repo stays tidy.
1412
+ *
1413
+ * Both no-op for the repo root (always implicit) and for paths that don't
1414
+ * exist. Both return `null` when nothing changed.
1415
+ */
1416
+ ensureFolderMarker(dir) {
1417
+ if (dir === this.repoPath)
1418
+ return null;
1419
+ if (!existsSync(dir))
1420
+ return null;
1421
+ const entries = readdirSync(dir);
1422
+ const hasMd = entries.some(e => e.endsWith('.md') && !e.startsWith('.'));
1423
+ if (hasMd)
1424
+ return null;
1425
+ const keepPath = join(dir, '.gitkeep');
1426
+ if (existsSync(keepPath))
1427
+ return null;
1428
+ writeFileSync(keepPath, '');
1429
+ return keepPath;
1430
+ }
1431
+ removeFolderMarker(dir) {
1432
+ if (dir === this.repoPath)
1433
+ return null;
1434
+ if (!existsSync(dir))
1435
+ return null;
1436
+ const keepPath = join(dir, '.gitkeep');
1437
+ if (!existsSync(keepPath))
1438
+ return null;
1439
+ const entries = readdirSync(dir);
1440
+ const hasMd = entries.some(e => e.endsWith('.md') && !e.startsWith('.'));
1441
+ if (!hasMd)
1442
+ return null; // marker still load-bearing
1443
+ rmSync(keepPath, { force: true });
1444
+ return keepPath;
1445
+ }
1446
+ /**
1447
+ * Walk up from `dir`, removing each directory that became empty after a
1448
+ * folder-level removal (deleteFolder, renameFolder, moveFolderToRepo).
1449
+ * Stops at the repo root. Silently no-ops if a directory is missing,
1450
+ * non-empty, or can't be removed.
1451
+ *
1452
+ * NOT called by entry-level operations (create/delete/move/transfer) —
1453
+ * they preserve folder structure regardless of whether they emptied a
1454
+ * directory. Use `ensureFolderMarker` instead for those paths.
1455
+ */
1456
+ async pruneEmptyAncestors(dir) {
1457
+ const { rmSync } = await import('fs');
1458
+ const repoRoot = this.repoPath.replace(/\/+$/, '');
1459
+ let current = dir;
1460
+ while (current.startsWith(repoRoot + '/') && current !== repoRoot) {
1461
+ if (!existsSync(current)) {
1462
+ current = dirname(current);
519
1463
  continue;
520
- const fullPath = join(this.repoPath, entry);
1464
+ }
1465
+ let entries;
521
1466
  try {
522
- if (!statSync(fullPath).isFile())
523
- continue;
1467
+ entries = readdirSync(current);
524
1468
  }
525
1469
  catch {
526
- continue;
1470
+ return;
527
1471
  }
528
- const id = entry.replace(/\.md$/, '');
529
- if (seenIds.has(id))
530
- continue;
1472
+ // Treat .gitkeep as "this folder is intentionally kept" — don't auto-prune.
1473
+ if (entries.length > 0)
1474
+ return;
531
1475
  try {
532
- const doc = this.markdownService.parseFile(fullPath);
533
- const fmType = doc.frontmatter.entity_type;
534
- const resolvedType = entityType ?? fmType ?? this.repoName;
535
- if (entityType && fmType && fmType !== entityType)
536
- continue;
537
- const validatedData = validateLenient(resolvedType, doc.frontmatter);
538
- seenIds.add(id);
539
- results.push({
540
- id,
541
- path: fullPath,
542
- entityType: resolvedType,
543
- data: validatedData,
544
- document: doc,
545
- });
1476
+ rmSync(current, { recursive: true, force: true });
546
1477
  }
547
- catch { /* skip invalid */ }
1478
+ catch {
1479
+ return;
1480
+ }
1481
+ current = dirname(current);
548
1482
  }
1483
+ }
1484
+ /**
1485
+ * List entities of a given type (or all types if not specified).
1486
+ *
1487
+ * Walks the entire repo tree so entries in nested folders are discovered.
1488
+ * The type filter is applied against frontmatter `entity_type`; legacy
1489
+ * entries without explicit frontmatter type fall back to the depth-1
1490
+ * plural-type directory name (so `meetings/foo.md` still resolves as a
1491
+ * meeting). Files with no inferable type (README.md, CONTRIBUTING.md, etc.)
1492
+ * are skipped.
1493
+ */
1494
+ list(entityType, limit) {
1495
+ if (!existsSync(this.repoPath))
1496
+ return [];
1497
+ const cacheKey = entityType ?? '*';
1498
+ const cached = this.listCache?.get(cacheKey);
1499
+ if (cached) {
1500
+ return limit && limit > 0 ? cached.slice(0, limit) : cached;
1501
+ }
1502
+ // Serve a type-filtered request from the unfiltered cache if present —
1503
+ // avoids re-walking the tree for each entity type on the same warm cache.
1504
+ if (entityType) {
1505
+ const all = this.listCache?.get('*');
1506
+ if (all) {
1507
+ const filtered = all.filter(e => e.entityType === entityType);
1508
+ if (!this.listCache)
1509
+ this.listCache = new Map();
1510
+ this.listCache.set(cacheKey, filtered);
1511
+ return limit && limit > 0 ? filtered.slice(0, limit) : filtered;
1512
+ }
1513
+ }
1514
+ const results = [];
1515
+ const seen = new Set(); // "type/id" — dedup if a file ends up indexed twice
1516
+ const validateLenient = (type, data) => {
1517
+ try {
1518
+ return this.validateEntity(type, data);
1519
+ }
1520
+ catch {
1521
+ try {
1522
+ return this.validateEntity(type, { ...data, entity_type: type }, true);
1523
+ }
1524
+ catch {
1525
+ return data;
1526
+ }
1527
+ }
1528
+ };
1529
+ this.walkMarkdownFiles((filePath) => {
1530
+ let doc;
1531
+ try {
1532
+ doc = this.markdownService.parseFile(filePath);
1533
+ }
1534
+ catch {
1535
+ return;
1536
+ }
1537
+ const fmType = doc.frontmatter.entity_type;
1538
+ const inferred = this.inferLegacyType(filePath);
1539
+ const resolvedType = (fmType ?? inferred);
1540
+ if (!resolvedType)
1541
+ return; // README, etc.
1542
+ if (entityType && resolvedType !== entityType)
1543
+ return;
1544
+ const id = filePath.split('/').pop().replace(/\.md$/, '');
1545
+ const key = `${resolvedType}/${id}`;
1546
+ if (seen.has(key))
1547
+ return;
1548
+ seen.add(key);
1549
+ const validatedData = validateLenient(resolvedType, doc.frontmatter);
1550
+ results.push({
1551
+ id,
1552
+ path: filePath,
1553
+ folder_path: folderPathFromFile(filePath, this.repoPath),
1554
+ entityType: resolvedType,
1555
+ data: validatedData,
1556
+ document: doc,
1557
+ });
1558
+ });
1559
+ if (!this.listCache)
1560
+ this.listCache = new Map();
1561
+ this.listCache.set(cacheKey, results);
549
1562
  if (limit && limit > 0)
550
1563
  return results.slice(0, limit);
551
1564
  return results;
@@ -554,17 +1567,74 @@ export class BaseGraphManager {
554
1567
  * Search entities
555
1568
  */
556
1569
  search(options) {
557
- const { entityType, query, filters, visibility, tags, dateRange, sortBy, limit } = options;
1570
+ const { entityType, query, filters, visibility, tags, dateRange, sortBy, limit, nameOnly } = options;
558
1571
  // Get all entities (optionally filtered by type)
559
1572
  let entities = this.list(entityType);
560
- // Apply query filter (full-text search in content)
1573
+ // Apply query filter (search across content + all string frontmatter fields)
1574
+ // Scores: 4 = name exact, 3 = name starts with, 2 = name contains, 1 = field match, 0 = body match
1575
+ let scored = null;
561
1576
  if (query && query.trim() !== '') {
562
1577
  const lowerQuery = query.toLowerCase();
563
- entities = entities.filter(e => {
564
- const content = e.document.content.toLowerCase();
1578
+ const EXCLUDED_SEARCH_FIELDS = new Set([
1579
+ 'entity_type', 'entity_id', 'created_by', 'updated_by',
1580
+ 'created_at', 'updated_at', 'date', 'start_date',
1581
+ 'deal_value', 'email', 'website', 'url', 'link', 'schema',
1582
+ ]);
1583
+ scored = [];
1584
+ for (const e of entities) {
565
1585
  const name = (e.data.name || '').toString().toLowerCase();
566
- return content.includes(lowerQuery) || name.includes(lowerQuery);
1586
+ let score = -1;
1587
+ // Name matching (highest priority)
1588
+ if (name === lowerQuery) {
1589
+ score = 4;
1590
+ }
1591
+ else if (name.startsWith(lowerQuery)) {
1592
+ score = 3;
1593
+ }
1594
+ else if (name.includes(lowerQuery)) {
1595
+ score = 2;
1596
+ }
1597
+ // Frontmatter field match — skipped in nameOnly mode (autosuggest).
1598
+ if (score < 0 && !nameOnly) {
1599
+ const data = e.data;
1600
+ for (const [key, val] of Object.entries(data)) {
1601
+ if (EXCLUDED_SEARCH_FIELDS.has(key) || key === 'name')
1602
+ continue;
1603
+ if (typeof val === 'string' && val.toLowerCase().includes(lowerQuery)) {
1604
+ score = 1;
1605
+ break;
1606
+ }
1607
+ if (Array.isArray(val)) {
1608
+ for (const item of val) {
1609
+ if (typeof item === 'string' && item.toLowerCase().includes(lowerQuery)) {
1610
+ score = 1;
1611
+ break;
1612
+ }
1613
+ }
1614
+ if (score >= 0)
1615
+ break;
1616
+ }
1617
+ }
1618
+ }
1619
+ // Body content match — skipped in nameOnly mode. Scanning every
1620
+ // entity's full markdown body is the dominant cost for keyword
1621
+ // search and is wasted on autosuggest, where the dropdown only
1622
+ // shows the name.
1623
+ if (score < 0 && !nameOnly && e.document.content.toLowerCase().includes(lowerQuery)) {
1624
+ score = 0;
1625
+ }
1626
+ if (score >= 0)
1627
+ scored.push({ entity: e, score });
1628
+ }
1629
+ // Sort by score desc, then by recency (updated_at desc) as tiebreaker
1630
+ scored.sort((a, b) => {
1631
+ if (b.score !== a.score)
1632
+ return b.score - a.score;
1633
+ const aDate = (a.entity.data.updated_at || '');
1634
+ const bDate = (b.entity.data.updated_at || '');
1635
+ return bDate.localeCompare(aDate);
567
1636
  });
1637
+ entities = scored.map(s => s.entity);
568
1638
  }
569
1639
  // Apply visibility filter
570
1640
  if (visibility && visibility.length > 0) {
@@ -655,31 +1725,43 @@ export class BaseGraphManager {
655
1725
  }
656
1726
  return entities;
657
1727
  }
1728
+ /**
1729
+ * Resolve a markdown file (anywhere under the repo) to its (type, id),
1730
+ * tolerating both legacy {plural-type}/{id}.md and nested-folder layouts.
1731
+ * Returns undefined for files without a recognizable entity type.
1732
+ */
1733
+ resolveFileToEntity(absPath) {
1734
+ const filename = absPath.split('/').pop();
1735
+ if (!filename || !filename.endsWith('.md'))
1736
+ return undefined;
1737
+ const id = filename.replace(/\.md$/, '');
1738
+ let fmType;
1739
+ try {
1740
+ fmType = this.markdownService.parseFile(absPath).frontmatter.entity_type;
1741
+ }
1742
+ catch { /* unparsable — fall back to inference */ }
1743
+ const type = fmType || this.inferLegacyType(absPath);
1744
+ if (!type)
1745
+ return undefined;
1746
+ return { type: type, id };
1747
+ }
658
1748
  /**
659
1749
  * Get entities related to a given entity (via wikilinks)
660
1750
  */
661
1751
  getRelated(entityType, entityId) {
662
1752
  const entity = this.get(entityType, entityId);
663
1753
  const related = [];
664
- // Find all wikilinked entities
665
1754
  for (const link of entity.document.wikilinks) {
666
- // Try to find the linked entity in all type subfolders
667
1755
  const found = this.markdownService.findEntity(link, [this.repoPath]);
668
- if (found) {
669
- const relPath = found.replace(this.repoPath + '/', '');
670
- const parts = relPath.split('/');
671
- // New layout: {plural-type}/{entityId}.md
672
- if (parts.length === 2 && parts[1].endsWith('.md')) {
673
- const linkedId = parts[1].replace(/\.md$/, '');
674
- try {
675
- const doc = this.markdownService.parseFile(found);
676
- const linkedType = doc.frontmatter.entity_type ?? PLURAL_TO_SINGULAR[parts[0]] ?? this.repoName;
677
- const linkedEntity = this.get(linkedType, linkedId);
678
- related.push(linkedEntity);
679
- }
680
- catch { /* skip invalid */ }
681
- }
1756
+ if (!found)
1757
+ continue;
1758
+ const resolved = this.resolveFileToEntity(found);
1759
+ if (!resolved)
1760
+ continue;
1761
+ try {
1762
+ related.push(this.get(resolved.type, resolved.id));
682
1763
  }
1764
+ catch { /* skip invalid */ }
683
1765
  }
684
1766
  return related;
685
1767
  }
@@ -690,19 +1772,13 @@ export class BaseGraphManager {
690
1772
  const backlinks = this.markdownService.findBacklinks(entityId, [this.repoPath]);
691
1773
  const results = [];
692
1774
  for (const { file } of backlinks) {
693
- const relPath = file.replace(this.repoPath + '/', '');
694
- const parts = relPath.split('/');
695
- // New layout: {plural-type}/{entityId}.md
696
- if (parts.length === 2 && parts[1].endsWith('.md')) {
697
- const linkedId = parts[1].replace(/\.md$/, '');
698
- try {
699
- const doc = this.markdownService.parseFile(file);
700
- const linkedType = doc.frontmatter.entity_type ?? PLURAL_TO_SINGULAR[parts[0]] ?? this.repoName;
701
- const linkedEntity = this.get(linkedType, linkedId);
702
- results.push(linkedEntity);
703
- }
704
- catch { /* skip invalid */ }
1775
+ const resolved = this.resolveFileToEntity(file);
1776
+ if (!resolved)
1777
+ continue;
1778
+ try {
1779
+ results.push(this.get(resolved.type, resolved.id));
705
1780
  }
1781
+ catch { /* skip invalid */ }
706
1782
  }
707
1783
  return results;
708
1784
  }
@@ -717,28 +1793,22 @@ export class BaseGraphManager {
717
1793
  * Refactor a wikilink (rename entity and update all references)
718
1794
  */
719
1795
  async refactorWikilink(oldId, newId, commitMessage) {
720
- // Find all files that reference the old entity ID
721
1796
  const backlinks = this.markdownService.findBacklinks(oldId, [this.repoPath]);
722
- // Update each file
723
1797
  if (this.gitService) {
724
1798
  const transaction = this.gitService.createTransaction();
725
1799
  for (const { file } of backlinks) {
726
1800
  this.markdownService.updateWikilinks(file, oldId, newId);
727
- const relPath = file.replace(this.repoPath + '/', '');
728
- const parts = relPath.split('/');
729
- // New layout: {plural-type}/{entityId}.md
730
- if (parts.length === 2 && parts[1].endsWith('.md')) {
731
- const entityId = parts[1].replace(/\.md$/, '');
732
- const doc = this.markdownService.parseFile(file);
733
- transaction.add({
734
- operation: 'update',
735
- entityType: doc.frontmatter.entity_type ?? PLURAL_TO_SINGULAR[parts[0]] ?? parts[0],
736
- entityId,
737
- filePath: file,
738
- });
739
- }
1801
+ const resolved = this.resolveFileToEntity(file);
1802
+ if (!resolved)
1803
+ continue;
1804
+ transaction.add({
1805
+ operation: 'update',
1806
+ entityType: resolved.type,
1807
+ entityId: resolved.id,
1808
+ filePath: file,
1809
+ });
740
1810
  }
741
- const message = commitMessage || `Refactor wikilink: ${oldId} → ${newId}`;
1811
+ const message = commitMessage || commitMsg.relinked(oldId, newId);
742
1812
  await transaction.commit(this.gitUser, message);
743
1813
  }
744
1814
  else {
@@ -748,6 +1818,612 @@ export class BaseGraphManager {
748
1818
  }
749
1819
  return { updated: backlinks.length };
750
1820
  }
1821
+ // ────────────────────────────────────────────────────────────────────────────
1822
+ // Folder CRUD — operate on directories within the repo, not on entries.
1823
+ // Sharing remains a repo-level concept; folders carry no permissions or
1824
+ // metadata of their own. Empty folders are kept around via .gitkeep so git
1825
+ // tracks them.
1826
+ // ────────────────────────────────────────────────────────────────────────────
1827
+ /**
1828
+ * List every non-empty folder in this repo, plus folders explicitly kept
1829
+ * via .gitkeep. Returns repo-relative POSIX paths, sorted, deduplicated.
1830
+ * The repo root ("") is intentionally omitted — it's implicit.
1831
+ */
1832
+ listFolders() {
1833
+ if (!existsSync(this.repoPath))
1834
+ return [];
1835
+ const folders = new Set();
1836
+ const sidecarByPath = new Map();
1837
+ const sidecarIdByPath = new Map();
1838
+ const stack = [this.repoPath];
1839
+ const repoRoot = this.repoPath.replace(/\/+$/, '');
1840
+ while (stack.length > 0) {
1841
+ const dir = stack.pop();
1842
+ let entries;
1843
+ try {
1844
+ entries = readdirSync(dir);
1845
+ }
1846
+ catch {
1847
+ continue;
1848
+ }
1849
+ let kept = false;
1850
+ let hasSidecar = false;
1851
+ for (const entry of entries) {
1852
+ if (entry === '.gitkeep')
1853
+ kept = true;
1854
+ if (entry === FOLDER_SIDECAR)
1855
+ hasSidecar = true;
1856
+ if (entry.startsWith('.'))
1857
+ continue;
1858
+ if (WALK_IGNORE.has(entry))
1859
+ continue;
1860
+ const full = join(dir, entry);
1861
+ let st;
1862
+ try {
1863
+ st = statSync(full);
1864
+ }
1865
+ catch {
1866
+ continue;
1867
+ }
1868
+ if (st.isDirectory())
1869
+ stack.push(full);
1870
+ }
1871
+ if (dir === repoRoot)
1872
+ continue;
1873
+ // Include this directory if it has any .md files OR a .gitkeep marker
1874
+ // OR a sidecar (which carries display metadata even for empty folders).
1875
+ const hasMarkdown = entries.some(e => e.endsWith('.md') && !e.startsWith('.'));
1876
+ if (hasMarkdown || kept || hasSidecar) {
1877
+ const folderPath = folderPathFromFile(join(dir, 'placeholder.md'), this.repoPath);
1878
+ folders.add(folderPath);
1879
+ if (hasSidecar) {
1880
+ const data = readFolderSidecarData(dir);
1881
+ if (data?.display_name)
1882
+ sidecarByPath.set(folderPath, data.display_name);
1883
+ if (data?.id)
1884
+ sidecarIdByPath.set(folderPath, data.id);
1885
+ }
1886
+ }
1887
+ }
1888
+ return [...folders]
1889
+ .filter(p => p.length > 0)
1890
+ .sort()
1891
+ .map(p => {
1892
+ const entry = { path: p, name: p.slice(p.lastIndexOf('/') + 1) };
1893
+ const display = sidecarByPath.get(p);
1894
+ if (display)
1895
+ entry.display_name = display;
1896
+ const id = sidecarIdByPath.get(p);
1897
+ if (id)
1898
+ entry.id = id;
1899
+ return entry;
1900
+ });
1901
+ }
1902
+ /**
1903
+ * Read the display_name override for a single folder, or null if no sidecar
1904
+ * exists / is unreadable. Used by mutation paths that need the current
1905
+ * override before deciding whether to rewrite it.
1906
+ */
1907
+ readFolderDisplayName(folderPath) {
1908
+ const normalized = validateFolderPath(folderPath);
1909
+ if (!normalized)
1910
+ return null;
1911
+ return readFolderSidecar(join(this.repoPath, normalized));
1912
+ }
1913
+ /**
1914
+ * Write or remove the per-folder display_name sidecar. Pass `null` /
1915
+ * empty-string to remove (returns to slug fallback). Returns the absolute
1916
+ * path of the sidecar (for git staging) when one was written or removed,
1917
+ * or null when no change was needed (idempotent).
1918
+ *
1919
+ * Caller is responsible for committing — the helper only touches the
1920
+ * filesystem. Folder must already exist; this never creates one.
1921
+ */
1922
+ writeFolderDisplayName(folderPath, displayName) {
1923
+ const normalized = validateFolderPath(folderPath);
1924
+ if (!normalized) {
1925
+ throw new InvalidFolderPathError('Folder path cannot be empty');
1926
+ }
1927
+ const abs = join(this.repoPath, normalized);
1928
+ if (!existsSync(abs)) {
1929
+ throw new Error(`Folder not found: ${normalized}`);
1930
+ }
1931
+ const trimmed = displayName?.trim();
1932
+ if (!trimmed) {
1933
+ // Clear the override but keep the sidecar (and its id) when one exists —
1934
+ // writeFolderSidecarData drops display_name and removes the file only if
1935
+ // nothing (no id) remains.
1936
+ const existing = readFolderSidecar(abs);
1937
+ if (existing === null)
1938
+ return null; // nothing to clear
1939
+ const result = writeFolderSidecarData(abs, { display_name: undefined });
1940
+ this.invalidateIndex();
1941
+ return result;
1942
+ }
1943
+ const existing = readFolderSidecar(abs);
1944
+ if (existing === trimmed)
1945
+ return null;
1946
+ const sidecarPath = writeFolderSidecar(abs, trimmed);
1947
+ this.invalidateIndex();
1948
+ return sidecarPath;
1949
+ }
1950
+ /**
1951
+ * Bulk-write display_name sidecars for many folders at once. Used by the
1952
+ * import path to record original-casing overrides for every nested
1953
+ * directory it slugified. First-writer-wins: existing sidecars are NOT
1954
+ * overwritten, so a second import doesn't clobber a user's manual rename.
1955
+ *
1956
+ * Returns the list of sidecar paths actually written (for git staging).
1957
+ * Skipped paths (folder missing, empty display, display_name override
1958
+ * already set) are quietly omitted.
1959
+ */
1960
+ recordFolderDisplays(entries) {
1961
+ const written = [];
1962
+ for (const entry of entries) {
1963
+ const normalized = validateFolderPath(entry.path);
1964
+ if (!normalized)
1965
+ continue;
1966
+ const abs = join(this.repoPath, normalized);
1967
+ if (!existsSync(abs))
1968
+ continue;
1969
+ // First-writer-wins on the display_name specifically — skip when an
1970
+ // override already exists (don't clobber a manual rename), but still
1971
+ // record onto an id-only sidecar (every folder now has one for its id).
1972
+ if (readFolderSidecar(abs) !== null)
1973
+ continue;
1974
+ const display = entry.display_name.trim();
1975
+ if (!display)
1976
+ continue;
1977
+ // Merge-safe: preserves the folder's id alongside the new display_name.
1978
+ written.push(writeFolderSidecar(abs, display));
1979
+ }
1980
+ if (written.length > 0)
1981
+ this.invalidateIndex();
1982
+ return written;
1983
+ }
1984
+ /**
1985
+ * Create a folder at the given path (mkdir -p semantics) and drop a
1986
+ * .gitkeep so git tracks it even when empty. Validates the path. No-op if
1987
+ * the folder already exists; .gitkeep is added if missing.
1988
+ */
1989
+ async createFolder(folderPath, commitMessage, user, options) {
1990
+ const normalized = validateFolderPath(folderPath);
1991
+ if (!normalized) {
1992
+ throw new InvalidFolderPathError('Folder path cannot be empty (use the repo root directly)');
1993
+ }
1994
+ const abs = join(this.repoPath, normalized);
1995
+ mkdirSync(abs, { recursive: true });
1996
+ const keep = join(abs, '.gitkeep');
1997
+ if (!existsSync(keep)) {
1998
+ writeFileSync(keep, '');
1999
+ }
2000
+ // Sidecar is written here (not after the commit) so it joins the same
2001
+ // commit as .gitkeep — folder creation is atomic from git's perspective.
2002
+ // A stable id is assigned eagerly when the folder doesn't already have one;
2003
+ // an existing id is never regenerated (createFolder doubles as an
2004
+ // ensure-folder no-op for paths that already exist).
2005
+ const sidecarPatch = {};
2006
+ if (!readFolderSidecarId(abs))
2007
+ sidecarPatch.id = randomUUID();
2008
+ if (options?.displayName !== undefined)
2009
+ sidecarPatch.display_name = options.displayName;
2010
+ const sidecarPath = Object.keys(sidecarPatch).length > 0
2011
+ ? writeFolderSidecarData(abs, sidecarPatch)
2012
+ : null;
2013
+ this.invalidateIndex();
2014
+ if (this.gitService) {
2015
+ const message = commitMessage || commitMsg.folderCreated(normalized);
2016
+ const files = sidecarPath ? [keep, sidecarPath] : [keep];
2017
+ this.gitService.commitFiles(files, message, user ?? this.gitUser);
2018
+ }
2019
+ }
2020
+ /**
2021
+ * Rename / move a folder within the repo. Moves the entire subtree
2022
+ * atomically via filesystem rename. Refuses if the target path is already
2023
+ * occupied.
2024
+ */
2025
+ async renameFolder(fromPath, toPath, commitMessage, user, options) {
2026
+ const fromNormalized = validateFolderPath(fromPath);
2027
+ const toNormalized = validateFolderPath(toPath);
2028
+ if (!fromNormalized)
2029
+ throw new InvalidFolderPathError('Source folder path required');
2030
+ if (!toNormalized)
2031
+ throw new InvalidFolderPathError('Target folder path required');
2032
+ // Display-only update: same path, just rewrite the sidecar.
2033
+ if (fromNormalized === toNormalized) {
2034
+ if (options?.displayName === undefined)
2035
+ return;
2036
+ const sidecarPath = this.writeFolderDisplayName(fromNormalized, options.displayName);
2037
+ if (sidecarPath && this.gitService) {
2038
+ const message = commitMessage || commitMsg.folderDisplayRenamed(fromNormalized);
2039
+ this.gitService.commitFiles([sidecarPath], message, user ?? this.gitUser);
2040
+ }
2041
+ return;
2042
+ }
2043
+ const fromAbs = join(this.repoPath, fromNormalized);
2044
+ const toAbs = join(this.repoPath, toNormalized);
2045
+ if (!existsSync(fromAbs)) {
2046
+ throw new Error(`Folder not found: ${fromNormalized}`);
2047
+ }
2048
+ if (existsSync(toAbs)) {
2049
+ throw new Error(`Target folder already exists: ${toNormalized}`);
2050
+ }
2051
+ mkdirSync(dirname(toAbs), { recursive: true });
2052
+ const { renameSync } = await import('fs');
2053
+ renameSync(fromAbs, toAbs);
2054
+ // The sidecar moved with the folder via FS rename. Update it now if the
2055
+ // caller passed a new display name (or `null` to clear). Idempotent — no
2056
+ // sidecar write happens when display matches the existing override.
2057
+ let sidecarPath = null;
2058
+ if (options?.displayName !== undefined) {
2059
+ sidecarPath = this.writeFolderDisplayName(toNormalized, options.displayName);
2060
+ }
2061
+ this.invalidateIndex();
2062
+ if (this.gitService) {
2063
+ const message = commitMessage || commitMsg.folderRenamed(fromNormalized, toNormalized);
2064
+ const files = sidecarPath ? [fromAbs, toAbs, sidecarPath] : [fromAbs, toAbs];
2065
+ this.gitService.commitFiles(files, message, user ?? this.gitUser);
2066
+ }
2067
+ await this.pruneEmptyAncestors(dirname(fromAbs));
2068
+ }
2069
+ /**
2070
+ * Move a folder (and its full subtree) into a different repo at `targetFolderPath`.
2071
+ *
2072
+ * The naive way to do this is to iterate the source's entries and call
2073
+ * `transfer` per entry — that's what the web UI used to do, and it costs
2074
+ * 2 git commits per entry (1600 commits for 800 files), each blocking the
2075
+ * Node event loop via `execSync`. This method performs the move as a
2076
+ * single FS-level rename + one commit per repo, regardless of how many
2077
+ * files are inside.
2078
+ *
2079
+ * Returns the count of entries + subfolders moved so the caller can
2080
+ * surface progress.
2081
+ */
2082
+ async moveFolderToRepo(sourceFolderPath, target, targetFolderPath, options) {
2083
+ const sourceNormalized = validateFolderPath(sourceFolderPath);
2084
+ if (!sourceNormalized) {
2085
+ throw new InvalidFolderPathError('Source folder path required');
2086
+ }
2087
+ const targetNormalized = targetFolderPath ? validateFolderPath(targetFolderPath) : '';
2088
+ if (targetFolderPath && !targetNormalized) {
2089
+ throw new InvalidFolderPathError('Invalid target folder path');
2090
+ }
2091
+ if (target === this && sourceNormalized === targetNormalized) {
2092
+ return { entriesDeleted: 0, subfoldersDeleted: 0 };
2093
+ }
2094
+ const sourceAbs = join(this.repoPath, sourceNormalized);
2095
+ if (!existsSync(sourceAbs)) {
2096
+ throw new Error(`Source folder not found: ${sourceNormalized}`);
2097
+ }
2098
+ const targetAbs = targetNormalized
2099
+ ? join(target.repoPath, targetNormalized)
2100
+ : target.repoPath;
2101
+ // Refuse if target already has visible content — caller must move into
2102
+ // an empty / nonexistent folder. (Hidden files like .studiograph are OK.)
2103
+ if (existsSync(targetAbs)) {
2104
+ const visible = readdirSync(targetAbs).filter(n => !n.startsWith('.'));
2105
+ if (visible.length > 0) {
2106
+ throw new Error(`Target folder is not empty: ${targetNormalized || '/'}`);
2107
+ }
2108
+ }
2109
+ const stats = this.countFolderContents(sourceAbs);
2110
+ // For a cross-repo move, capture the entities in the tree BEFORE the
2111
+ // rename so we can re-point their asset ownership to the target repo
2112
+ // afterward (serve-time authz reads the index's repo column).
2113
+ const ownersToReassign = [];
2114
+ if (this.assetIndex && target.repoName !== this.repoName) {
2115
+ const stack = [sourceAbs];
2116
+ while (stack.length > 0) {
2117
+ const dir = stack.pop();
2118
+ let names;
2119
+ try {
2120
+ names = readdirSync(dir);
2121
+ }
2122
+ catch {
2123
+ continue;
2124
+ }
2125
+ for (const name of names) {
2126
+ if (name.startsWith('.') || WALK_IGNORE.has(name))
2127
+ continue;
2128
+ const full = join(dir, name);
2129
+ let st;
2130
+ try {
2131
+ st = statSync(full);
2132
+ }
2133
+ catch {
2134
+ continue;
2135
+ }
2136
+ if (st.isDirectory())
2137
+ stack.push(full);
2138
+ else if (st.isFile() && name.endsWith('.md')) {
2139
+ const ent = this.identifyEntityAtPath(full);
2140
+ if (ent)
2141
+ ownersToReassign.push({ entityType: ent.entityType, entityId: ent.entityId });
2142
+ }
2143
+ }
2144
+ }
2145
+ }
2146
+ // Atomic FS rename if possible. Falls back to copy + delete if the two
2147
+ // repos sit on different filesystems (rare, but possible on Docker
2148
+ // bind mounts).
2149
+ const { renameSync, cpSync, rmSync } = await import('fs');
2150
+ mkdirSync(dirname(targetAbs), { recursive: true });
2151
+ if (existsSync(targetAbs)) {
2152
+ // Empty pre-existing dir — drop it so renameSync can take its place.
2153
+ rmSync(targetAbs, { recursive: true, force: true });
2154
+ }
2155
+ try {
2156
+ renameSync(sourceAbs, targetAbs);
2157
+ }
2158
+ catch (err) {
2159
+ if (err?.code === 'EXDEV') {
2160
+ cpSync(sourceAbs, targetAbs, { recursive: true });
2161
+ rmSync(sourceAbs, { recursive: true, force: true });
2162
+ }
2163
+ else {
2164
+ throw err;
2165
+ }
2166
+ }
2167
+ this.invalidateIndex();
2168
+ target.invalidateIndex();
2169
+ const user = options?.user ?? this.gitUser;
2170
+ const sourceMessage = options?.commitMessage
2171
+ ?? commitMsg.folderMoved(sourceNormalized, target.repoName, targetNormalized);
2172
+ const targetMessage = options?.commitMessage
2173
+ ?? commitMsg.folderReceived(this.repoName, sourceNormalized, targetNormalized);
2174
+ if (this.gitService) {
2175
+ this.gitService.commitFiles([sourceAbs], sourceMessage, user);
2176
+ }
2177
+ if (target.gitService) {
2178
+ target.gitService.commitFiles([targetAbs], targetMessage, user);
2179
+ }
2180
+ // Cross-repo: re-point asset ownership to the target repo (see capture above).
2181
+ for (const o of ownersToReassign) {
2182
+ this.assetIndex?.reassignOwner(this.repoName, o.entityType, o.entityId, { repo: target.repoName });
2183
+ }
2184
+ await this.pruneEmptyAncestors(dirname(sourceAbs));
2185
+ return stats;
2186
+ }
2187
+ /**
2188
+ * Move a batch of entries (potentially across many source folders) into a
2189
+ * single target folder, optionally in a different repo. The web UI's
2190
+ * multi-select bulk move used to call /move or /transfer per entry — that
2191
+ * was 1-2 sync git commits per entry, blocking the event loop on each one.
2192
+ * This collapses the operation to a single FS rename per file plus one
2193
+ * commit on each repo touched.
2194
+ */
2195
+ async moveEntitiesToRepo(entries, target, targetFolderPath, options) {
2196
+ if (entries.length === 0)
2197
+ return { moved: 0 };
2198
+ const targetNormalized = targetFolderPath ? validateFolderPath(targetFolderPath) : '';
2199
+ if (targetFolderPath && !targetNormalized) {
2200
+ throw new InvalidFolderPathError('Invalid target folder path');
2201
+ }
2202
+ const targetDir = targetNormalized
2203
+ ? join(target.repoPath, targetNormalized)
2204
+ : target.repoPath;
2205
+ // Resolve all source paths up front and validate target conflicts before
2206
+ // any FS work — partial moves on conflict would leave the user with a
2207
+ // confusing half-state.
2208
+ const moves = [];
2209
+ for (const entry of entries) {
2210
+ const existing = this.get(entry.entityType, entry.entityId);
2211
+ const dst = join(targetDir, `${entry.entityId}.md`);
2212
+ if (existing.path === dst)
2213
+ continue; // already there — no-op
2214
+ if (existsSync(dst)) {
2215
+ throw new Error(`Path already occupied: ${target.repoName}:${targetNormalized ? targetNormalized + '/' : ''}${entry.entityId}.md`);
2216
+ }
2217
+ moves.push({ src: existing.path, dst });
2218
+ }
2219
+ if (moves.length === 0)
2220
+ return { moved: 0 };
2221
+ const { renameSync, cpSync } = await import('fs');
2222
+ mkdirSync(targetDir, { recursive: true });
2223
+ for (const m of moves) {
2224
+ try {
2225
+ renameSync(m.src, m.dst);
2226
+ }
2227
+ catch (err) {
2228
+ if (err?.code === 'EXDEV') {
2229
+ cpSync(m.src, m.dst);
2230
+ rmSync(m.src);
2231
+ }
2232
+ else {
2233
+ throw err;
2234
+ }
2235
+ }
2236
+ }
2237
+ // Folder lifecycle: ensure markers in every source dir we may have
2238
+ // emptied, drop the marker in the (single) target dir now that it has
2239
+ // .md children. Helpers no-op for the repo root and for paths that
2240
+ // are already in the right state, so calling them per dir is cheap.
2241
+ const sourceMarkers = [];
2242
+ const visitedSourceDirs = new Set();
2243
+ for (const m of moves) {
2244
+ const sourceDir = dirname(m.src);
2245
+ if (visitedSourceDirs.has(sourceDir))
2246
+ continue;
2247
+ visitedSourceDirs.add(sourceDir);
2248
+ const added = this.ensureFolderMarker(sourceDir);
2249
+ if (added)
2250
+ sourceMarkers.push(added);
2251
+ }
2252
+ const removedTargetMarker = target.removeFolderMarker(targetDir);
2253
+ this.invalidateIndex();
2254
+ if (target !== this)
2255
+ target.invalidateIndex();
2256
+ const user = options?.user ?? this.gitUser;
2257
+ const where = target === this
2258
+ ? (targetNormalized || '(root)')
2259
+ : `${target.repoName}:${targetNormalized || '(root)'}`;
2260
+ const sourceMessage = options?.commitMessage ?? commitMsg.movedBatch(moves.length, where);
2261
+ if (this.gitService) {
2262
+ const sourcePaths = [...moves.map(m => m.src), ...sourceMarkers];
2263
+ const dstStaging = target === this
2264
+ ? [...moves.map(m => m.dst), ...(removedTargetMarker ? [removedTargetMarker] : [])]
2265
+ : [];
2266
+ this.gitService.commitFiles([...sourcePaths, ...dstStaging], sourceMessage, user);
2267
+ }
2268
+ if (target !== this && target.gitService) {
2269
+ const targetMessage = options?.commitMessage
2270
+ ?? commitMsg.receivedBatch(moves.length, this.repoName);
2271
+ const targetPaths = [...moves.map(m => m.dst), ...(removedTargetMarker ? [removedTargetMarker] : [])];
2272
+ target.gitService.commitFiles(targetPaths, targetMessage, user);
2273
+ }
2274
+ // Cross-repo move: re-point asset ownership to the target repo so the
2275
+ // serve route's per-repo authz resolves against the new repo. No blob
2276
+ // movement (keys are immutable); the index is workspace-global so source
2277
+ // and target share it. Same-repo folder moves leave repo unchanged.
2278
+ if (target.repoName !== this.repoName) {
2279
+ for (const entry of entries) {
2280
+ this.assetIndex?.reassignOwner(this.repoName, entry.entityType, entry.entityId, { repo: target.repoName });
2281
+ }
2282
+ }
2283
+ return { moved: moves.length };
2284
+ }
2285
+ /**
2286
+ * Delete a folder. Refuses non-empty folders unless `recursive: true`.
2287
+ * Returns the count of entries + subfolders removed (for blast-radius UI).
2288
+ */
2289
+ async deleteFolder(folderPath, options) {
2290
+ const normalized = validateFolderPath(folderPath);
2291
+ if (!normalized) {
2292
+ throw new InvalidFolderPathError('Cannot delete the repo root via deleteFolder');
2293
+ }
2294
+ const abs = join(this.repoPath, normalized);
2295
+ if (!existsSync(abs)) {
2296
+ throw new Error(`Folder not found: ${normalized}`);
2297
+ }
2298
+ const stats = this.countFolderContents(abs);
2299
+ if (!options?.recursive && (stats.entriesDeleted > 0 || stats.subfoldersDeleted > 0)) {
2300
+ const err = new Error(`Folder is not empty: ${normalized} (${stats.entriesDeleted} entries, ${stats.subfoldersDeleted} subfolders). Pass recursive: true to delete contents.`);
2301
+ err.code = 'FOLDER_NOT_EMPTY';
2302
+ err.blastRadius = stats;
2303
+ throw err;
2304
+ }
2305
+ // Collect the entities in the tree BEFORE removing it, so we can clean
2306
+ // their immutable-keyed assets (the rmSync below takes the whole subtree
2307
+ // at once, so per-entity (type,id) must be captured first).
2308
+ const ownersToClean = [];
2309
+ if (this.assetIndex) {
2310
+ const stack = [abs];
2311
+ while (stack.length > 0) {
2312
+ const dir = stack.pop();
2313
+ let entries;
2314
+ try {
2315
+ entries = readdirSync(dir);
2316
+ }
2317
+ catch {
2318
+ continue;
2319
+ }
2320
+ for (const entry of entries) {
2321
+ if (entry.startsWith('.') || WALK_IGNORE.has(entry))
2322
+ continue;
2323
+ const full = join(dir, entry);
2324
+ let st;
2325
+ try {
2326
+ st = statSync(full);
2327
+ }
2328
+ catch {
2329
+ continue;
2330
+ }
2331
+ if (st.isDirectory())
2332
+ stack.push(full);
2333
+ else if (st.isFile() && entry.endsWith('.md')) {
2334
+ const ent = this.identifyEntityAtPath(full);
2335
+ if (ent)
2336
+ ownersToClean.push({ entityType: ent.entityType, entityId: ent.entityId });
2337
+ }
2338
+ }
2339
+ }
2340
+ }
2341
+ const { rmSync } = await import('fs');
2342
+ rmSync(abs, { recursive: true, force: true });
2343
+ this.invalidateIndex();
2344
+ if (this.gitService) {
2345
+ const message = options?.commitMessage || commitMsg.folderDeleted(normalized);
2346
+ this.gitService.commitFiles([abs], message, options?.user ?? this.gitUser);
2347
+ }
2348
+ for (const o of ownersToClean) {
2349
+ await this.cleanupOwnedAssets(o.entityType, o.entityId);
2350
+ }
2351
+ await this.pruneEmptyAncestors(dirname(abs));
2352
+ return stats;
2353
+ }
2354
+ /**
2355
+ * Count the entries (.md files with entity_type) and subfolders inside an
2356
+ * absolute directory, recursively. Used to surface blast radius in the
2357
+ * delete-folder approval UI.
2358
+ */
2359
+ countFolderContents(absDir) {
2360
+ let entriesDeleted = 0;
2361
+ const subfolders = new Set();
2362
+ const stack = [absDir];
2363
+ while (stack.length > 0) {
2364
+ const dir = stack.pop();
2365
+ let entries;
2366
+ try {
2367
+ entries = readdirSync(dir);
2368
+ }
2369
+ catch {
2370
+ continue;
2371
+ }
2372
+ for (const entry of entries) {
2373
+ if (entry.startsWith('.'))
2374
+ continue;
2375
+ if (WALK_IGNORE.has(entry))
2376
+ continue;
2377
+ const full = join(dir, entry);
2378
+ let st;
2379
+ try {
2380
+ st = statSync(full);
2381
+ }
2382
+ catch {
2383
+ continue;
2384
+ }
2385
+ if (st.isDirectory()) {
2386
+ subfolders.add(full);
2387
+ stack.push(full);
2388
+ }
2389
+ else if (st.isFile() && entry.endsWith('.md')) {
2390
+ entriesDeleted += 1;
2391
+ }
2392
+ }
2393
+ }
2394
+ return { entriesDeleted, subfoldersDeleted: subfolders.size };
2395
+ }
2396
+ /**
2397
+ * Return the storage backend the graph is bound to, or `undefined` if
2398
+ * no asset service is configured. Used by routes that need to branch
2399
+ * between local-stream and R2-redirect behavior.
2400
+ */
2401
+ getAssetStorage() {
2402
+ return this.assetService?.getStorage();
2403
+ }
2404
+ /** The asset storage service (raw key copy/exists), if configured. Used by
2405
+ * the one-time key migration, which operates on raw `{...}/{file}` keys. */
2406
+ getAssetService() {
2407
+ return this.assetService;
2408
+ }
2409
+ /** Pre-signed URL for an immutable-key asset ({assetId}/{filename}). */
2410
+ async getAssetSignedUrlByAssetId(assetId, filename, method = 'GET', expiresIn) {
2411
+ if (!this.assetService) {
2412
+ throw new Error('Asset service is not configured for this graph');
2413
+ }
2414
+ return await this.assetService.getSignedUrlByAssetId(assetId, filename, expiresIn, method);
2415
+ }
2416
+ /**
2417
+ * Mint a short-lived pre-signed URL for an asset. Only supported by
2418
+ * remote backends (R2); the local backend throws. `method` selects
2419
+ * the verb the URL is signed against — HEAD callers need a HEAD URL.
2420
+ */
2421
+ async getAssetSignedUrl(entityType, entityId, filename, keyPrefix, expiresIn, method = 'GET') {
2422
+ if (!this.assetService) {
2423
+ throw new Error('Asset service is not configured for this graph');
2424
+ }
2425
+ return await this.assetService.getSignedUrl(entityType, entityId, filename, keyPrefix, expiresIn, method);
2426
+ }
751
2427
  /**
752
2428
  * Upload asset for an entity
753
2429
  */
@@ -755,7 +2431,64 @@ export class BaseGraphManager {
755
2431
  if (!this.assetService) {
756
2432
  throw new Error('Asset service is not configured for this graph');
757
2433
  }
758
- return await this.assetService.upload(entityType, entityId, filename, buffer, keyPrefix);
2434
+ // Immutable-key path: generate a permanent assetId, store under
2435
+ // {assetId}/{filename}, and record authoritative ownership in the index.
2436
+ // The owning entity (entityType, entityId) is metadata only — renaming or
2437
+ // retyping the entity never moves the blob. `keyPrefix` is irrelevant here
2438
+ // (it only shaped the legacy {prefix}/{id} key).
2439
+ if (this.assetIndex) {
2440
+ const assetId = randomUUID();
2441
+ const info = await this.assetService.uploadByAssetId(assetId, filename, buffer);
2442
+ this.assetIndex.put({
2443
+ assetId,
2444
+ repo: this.repoName,
2445
+ ownerType: entityType,
2446
+ ownerId: entityId,
2447
+ filename: info.filename,
2448
+ contentType: info.contentType,
2449
+ mediaType: info.mediaType,
2450
+ name: filename,
2451
+ });
2452
+ return { ...info, assetId, publicUrl: publicUrlForAssetId(assetId, info.filename) };
2453
+ }
2454
+ // Legacy fallback (no index wired — tests/CLI): {type|prefix}/{id}/{file}.
2455
+ const info = await this.assetService.upload(entityType, entityId, filename, buffer, keyPrefix);
2456
+ return { ...info, publicUrl: publicUrlFor(info, keyPrefix || entityType, entityId) };
2457
+ }
2458
+ /** Inject the workspace-global asset ownership index (WorkspaceManager). */
2459
+ setAssetIndex(index) {
2460
+ this.assetIndex = index;
2461
+ }
2462
+ /** The asset ownership index, if wired. */
2463
+ getAssetIndex() {
2464
+ return this.assetIndex;
2465
+ }
2466
+ /**
2467
+ * Remove every immutable-keyed asset owned by (entityType, entityId): the
2468
+ * blob + its index row. Used by delete/bulkDelete/deleteFolder. Blob errors
2469
+ * are non-fatal (markdown is the system of record). No-op without an index.
2470
+ * Owner-scoped, so it only touches THIS entity's assets — a blob another
2471
+ * entity also embeds (copied URL) is not enumerated here.
2472
+ */
2473
+ async cleanupOwnedAssets(entityType, entityId) {
2474
+ if (!this.assetIndex || !this.assetService)
2475
+ return;
2476
+ for (const row of this.assetIndex.listByOwner(this.repoName, entityType, entityId)) {
2477
+ try {
2478
+ await this.assetService.deleteByAssetId(row.assetId, row.filename);
2479
+ }
2480
+ catch (err) {
2481
+ console.warn(`[asset] delete blob ${row.assetId} failed: ${err?.message ?? err}`);
2482
+ }
2483
+ this.assetIndex.delete(row.assetId);
2484
+ }
2485
+ }
2486
+ /** Re-point the owning coordinates of every asset owned by an entity, with
2487
+ * no blob movement (keys are immutable). Used by rename (ownerId),
2488
+ * changeType (ownerType), and cross-repo move/transfer (repo). No-op
2489
+ * without an index. */
2490
+ reassignOwnedAssets(fromType, fromId, changes) {
2491
+ this.assetIndex?.reassignOwner(this.repoName, fromType, fromId, changes);
759
2492
  }
760
2493
  /**
761
2494
  * List assets for an entity
@@ -766,6 +2499,47 @@ export class BaseGraphManager {
766
2499
  }
767
2500
  return await this.assetService.list(entityType, entityId, keyPrefix);
768
2501
  }
2502
+ /**
2503
+ * List every asset in the backend in one round-trip, tagged with
2504
+ * (entityType, entityId) origin. Workspace-wide since the asset backend
2505
+ * is shared across repos — callers filter by repo via {@link listEntityIds}.
2506
+ */
2507
+ async listAllAssets() {
2508
+ if (!this.assetService) {
2509
+ throw new Error('Asset service is not configured for this graph');
2510
+ }
2511
+ return await this.assetService.listAll();
2512
+ }
2513
+ /**
2514
+ * Enumerate (type, id) pairs in this repo. Used by endpoints (like
2515
+ * /api/repos/:repo/assets) that need to answer "does entity X belong to
2516
+ * this repo?".
2517
+ *
2518
+ * Backed by the file index, which reads frontmatter — so entries in
2519
+ * nested folders are included alongside legacy {plural-type}/ entries.
2520
+ */
2521
+ listEntityIds() {
2522
+ if (!existsSync(this.repoPath))
2523
+ return [];
2524
+ const results = [];
2525
+ for (const key of this.getFileIndex().keys()) {
2526
+ const slash = key.indexOf('/');
2527
+ if (slash <= 0)
2528
+ continue;
2529
+ results.push({ entityType: key.slice(0, slash), id: key.slice(slash + 1) });
2530
+ }
2531
+ return results;
2532
+ }
2533
+ /**
2534
+ * Resolve the absolute on-disk markdown path for an entity. Goes through
2535
+ * the file index so it works for both the flat repo-root layout and
2536
+ * arbitrary nested folders. Use this anywhere callers need a path —
2537
+ * never reconstruct one from `${plural-type}/${id}.md`, which assumes
2538
+ * the legacy typed-folder layout and silently misses everything else.
2539
+ */
2540
+ getEntityFilePath(entityType, entityId) {
2541
+ return this.findEntityFile(entityType, entityId);
2542
+ }
769
2543
  /**
770
2544
  * Delete an asset
771
2545
  */
@@ -785,66 +2559,96 @@ export class BaseGraphManager {
785
2559
  return await this.assetService.deleteAll(entityType, entityId, keyPrefix);
786
2560
  }
787
2561
  /**
788
- * Get dataset rows, optionally filtered
2562
+ * Resolve the authoritative CSV body for a dataset: the live editor body
2563
+ * when one exists (the grid may be ahead of disk), else the on-disk body.
2564
+ */
2565
+ datasetBody(entity, entityId) {
2566
+ return this.getLiveBody('dataset', entityId) ?? entity.document.content;
2567
+ }
2568
+ /**
2569
+ * Get dataset rows, optionally filtered. Columns come from the CSV header.
789
2570
  */
790
2571
  getDatasetRows(entityId, filter) {
791
2572
  const entity = this.get('dataset', entityId);
792
2573
  if (entity.data.entity_type !== 'dataset') {
793
2574
  throw new Error(`Entity ${entityId} is not a dataset`);
794
2575
  }
795
- const schema = entity.data.schema;
796
- const rows = this.csvService.parseRows(entity.document.content, schema);
2576
+ const rows = this.csvService.parseRows(this.datasetBody(entity, entityId));
797
2577
  if (filter && Object.keys(filter).length > 0) {
798
2578
  return this.csvService.filterRows(rows, filter);
799
2579
  }
800
2580
  return rows;
801
2581
  }
802
2582
  /**
803
- * Append rows to a dataset entity
2583
+ * Column names for a dataset, read from the CSV header. Independent of the
2584
+ * rows so callers get the real columns even for a header-only dataset or a
2585
+ * zero-match filter (where deriving columns from result rows would be empty).
2586
+ */
2587
+ getDatasetColumns(entityId) {
2588
+ const entity = this.get('dataset', entityId);
2589
+ if (entity.data.entity_type !== 'dataset') {
2590
+ throw new Error(`Entity ${entityId} is not a dataset`);
2591
+ }
2592
+ return this.csvService.columns(this.datasetBody(entity, entityId));
2593
+ }
2594
+ /**
2595
+ * Append rows to a dataset entity.
2596
+ *
2597
+ * The existing CSV body is preserved verbatim and the new rows are appended
2598
+ * after it — we do NOT re-parse and re-stringify existing rows. That keeps
2599
+ * datasets with duplicate column headers from collapsing (csv-parse's
2600
+ * object mode keeps only the last same-named column) and avoids reformatting
2601
+ * unrelated cells. New rows are mapped to the existing header columns; keys
2602
+ * not in the header are dropped (query the columns first). When the body has
2603
+ * no header yet, the columns are taken from the new rows.
2604
+ *
2605
+ * The write routes through `update()` so it shares the BodyWriteCoordinator
2606
+ * contract (per-doc lock, base-hash precondition, applyAgentContent, commit)
2607
+ * instead of writing to disk directly — same guarantees the agent's markdown
2608
+ * writes get. `baseRevision` makes a concurrent change since our read fail
2609
+ * loudly rather than silently back-stomp.
804
2610
  */
805
2611
  async appendDatasetRows(entityId, newRows, commitMessage) {
806
2612
  const entity = this.get('dataset', entityId);
807
2613
  if (entity.data.entity_type !== 'dataset') {
808
2614
  throw new Error(`Entity ${entityId} is not a dataset`);
809
2615
  }
810
- const schema = entity.data.schema;
811
- // Validate new rows against schema
812
- const validatedRows = newRows.map(row => {
813
- const stringRow = {};
2616
+ const base = this.datasetBody(entity, entityId);
2617
+ // Normalize incoming rows to string cells (agents may send numbers/bools).
2618
+ const normalizedRows = newRows.map(row => {
2619
+ const out = {};
814
2620
  for (const [k, v] of Object.entries(row)) {
815
- stringRow[k] = v !== null && v !== undefined ? String(v) : '';
2621
+ out[k] = v !== null && v !== undefined ? String(v) : '';
816
2622
  }
817
- return this.csvService.validateRow(stringRow, schema);
2623
+ return out;
818
2624
  });
819
- // Parse existing rows and append
820
- const existingRows = this.csvService.parseRows(entity.document.content, schema);
821
- const allRows = [...existingRows, ...validatedRows];
822
- // Stringify back to CSV
823
- const newContent = this.csvService.stringifyRows(allRows, schema);
824
- // Update the entity file (update content, bump updated_at)
825
- const entityPath = this.getEntityPath('dataset', entityId);
826
- const updatedData = {
827
- ...entity.data,
828
- updated_at: new Date().toISOString(),
829
- updated_by: this.gitUser.id,
830
- };
831
- this.markdownService.writeFile(entityPath, {
832
- frontmatter: updatedData,
2625
+ let newContent;
2626
+ const headerColumns = this.csvService.columns(base);
2627
+ if (headerColumns.length === 0) {
2628
+ // No header yet: derive columns from the new rows and write a fresh CSV.
2629
+ const columns = [];
2630
+ for (const row of normalizedRows) {
2631
+ for (const key of Object.keys(row)) {
2632
+ if (!columns.includes(key))
2633
+ columns.push(key);
2634
+ }
2635
+ }
2636
+ newContent = this.csvService.stringifyRows(normalizedRows, columns);
2637
+ }
2638
+ else {
2639
+ // Preserve the existing body byte-for-byte; append only the new rows.
2640
+ const appended = this.csvService.stringifyRows(normalizedRows, headerColumns, false);
2641
+ const baseTrimmed = base.replace(/\n+$/, '');
2642
+ newContent = appended ? `${baseTrimmed}\n${appended}` : `${baseTrimmed}\n`;
2643
+ }
2644
+ return this.update({
2645
+ entityType: 'dataset',
2646
+ entityId,
833
2647
  content: newContent,
834
- wikilinks: this.markdownService.extractWikilinks(newContent, updatedData),
2648
+ source: 'server-write',
2649
+ baseRevision: sha256OfBody(base),
2650
+ commitMessage: commitMessage || `Append ${normalizedRows.length} row(s) to dataset: ${entityId}`,
835
2651
  });
836
- if (this.gitService) {
837
- const transaction = this.gitService.createTransaction();
838
- transaction.add({
839
- operation: 'update',
840
- entityType: 'dataset',
841
- entityId,
842
- filePath: entityPath,
843
- });
844
- const message = commitMessage || `Append ${validatedRows.length} row(s) to dataset: ${entityId}`;
845
- await transaction.commit(this.gitUser, message);
846
- }
847
- return this.get('dataset', entityId);
848
2652
  }
849
2653
  /**
850
2654
  * Get repository status