studiograph 1.3.48-next.21 → 1.3.48-next.210

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 (966) hide show
  1. package/README.md +26 -16
  2. package/dist/agent/agent-pool.d.ts +87 -3
  3. package/dist/agent/agent-pool.js +188 -15
  4. package/dist/agent/agent-pool.js.map +1 -1
  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 +101 -4
  9. package/dist/agent/orchestrator.js +464 -66
  10. package/dist/agent/orchestrator.js.map +1 -1
  11. package/dist/agent/prompts/entity-types-section.d.ts +34 -0
  12. package/dist/agent/prompts/entity-types-section.js +76 -0
  13. package/dist/agent/prompts/entity-types-section.js.map +1 -0
  14. package/dist/agent/prompts/system.md +112 -54
  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/artifact-canvas/skill.md +152 -0
  19. package/dist/agent/skills/entity-schema.md +75 -431
  20. package/dist/agent/skills/interview/entity-templates.md +38 -37
  21. package/dist/agent/skills/interview/question-domains.md +54 -49
  22. package/dist/agent/skills/interview/skill.md +84 -16
  23. package/dist/agent/skills/schema-rules.md +28 -28
  24. package/dist/agent/tools/artifact-tools.d.ts +124 -0
  25. package/dist/agent/tools/artifact-tools.js +207 -0
  26. package/dist/agent/tools/artifact-tools.js.map +1 -0
  27. package/dist/agent/tools/capture-tools.d.ts +31 -0
  28. package/dist/agent/tools/capture-tools.js +40 -0
  29. package/dist/agent/tools/capture-tools.js.map +1 -0
  30. package/dist/agent/tools/folder-tools.d.ts +47 -0
  31. package/dist/agent/tools/folder-tools.js +249 -0
  32. package/dist/agent/tools/folder-tools.js.map +1 -0
  33. package/dist/agent/tools/fs-tools.d.ts +6 -5
  34. package/dist/agent/tools/fs-tools.js +5 -5
  35. package/dist/agent/tools/fs-tools.js.map +1 -1
  36. package/dist/agent/tools/graph-tools.d.ts +48 -72
  37. package/dist/agent/tools/graph-tools.js +852 -329
  38. package/dist/agent/tools/graph-tools.js.map +1 -1
  39. package/dist/agent/tools/history-tools.d.ts +95 -0
  40. package/dist/agent/tools/history-tools.js +461 -0
  41. package/dist/agent/tools/history-tools.js.map +1 -0
  42. package/dist/agent/tools/load-skill.d.ts +6 -5
  43. package/dist/agent/tools/load-skill.js +3 -3
  44. package/dist/agent/tools/load-skill.js.map +1 -1
  45. package/dist/agent/tools/ops-tools.d.ts +5 -4
  46. package/dist/agent/tools/ops-tools.js +130 -236
  47. package/dist/agent/tools/ops-tools.js.map +1 -1
  48. package/dist/agent/tools/permission-tools.d.ts +87 -17
  49. package/dist/agent/tools/permission-tools.js +233 -43
  50. package/dist/agent/tools/permission-tools.js.map +1 -1
  51. package/dist/agent/tools/tool-loader.js +5 -4
  52. package/dist/agent/tools/tool-loader.js.map +1 -1
  53. package/dist/agent/tools/web-tools.js +58 -9
  54. package/dist/agent/tools/web-tools.js.map +1 -1
  55. package/dist/cli/commands/about.d.ts +1 -0
  56. package/dist/cli/commands/about.js +55 -3
  57. package/dist/cli/commands/about.js.map +1 -1
  58. package/dist/cli/commands/admin/audit-workspace.d.ts +23 -0
  59. package/dist/cli/commands/admin/audit-workspace.js +133 -0
  60. package/dist/cli/commands/admin/audit-workspace.js.map +1 -0
  61. package/dist/cli/commands/admin/audit.d.ts +21 -0
  62. package/dist/cli/commands/admin/audit.js +174 -0
  63. package/dist/cli/commands/admin/audit.js.map +1 -0
  64. package/dist/cli/commands/admin/backup.d.ts +54 -0
  65. package/dist/cli/commands/admin/backup.js +207 -0
  66. package/dist/cli/commands/admin/backup.js.map +1 -0
  67. package/dist/cli/commands/admin/folder.d.ts +11 -0
  68. package/dist/cli/commands/{collection.js → admin/folder.js} +33 -32
  69. package/dist/cli/commands/admin/folder.js.map +1 -0
  70. package/dist/cli/commands/admin/index.d.ts +16 -0
  71. package/dist/cli/commands/admin/index.js +46 -0
  72. package/dist/cli/commands/admin/index.js.map +1 -0
  73. package/dist/cli/commands/admin/migrate-assets.d.ts +53 -0
  74. package/dist/cli/commands/admin/migrate-assets.js +184 -0
  75. package/dist/cli/commands/admin/migrate-assets.js.map +1 -0
  76. package/dist/cli/commands/admin/migrate.d.ts +56 -0
  77. package/dist/cli/commands/admin/migrate.js +263 -0
  78. package/dist/cli/commands/admin/migrate.js.map +1 -0
  79. package/dist/cli/commands/admin/restore.d.ts +24 -0
  80. package/dist/cli/commands/admin/restore.js +228 -0
  81. package/dist/cli/commands/admin/restore.js.map +1 -0
  82. package/dist/cli/commands/admin/secrets.d.ts +23 -0
  83. package/dist/cli/commands/admin/secrets.js +256 -0
  84. package/dist/cli/commands/admin/secrets.js.map +1 -0
  85. package/dist/cli/commands/admin/settings.d.ts +28 -0
  86. package/dist/cli/commands/admin/settings.js +284 -0
  87. package/dist/cli/commands/admin/settings.js.map +1 -0
  88. package/dist/cli/commands/admin/token.d.ts +42 -0
  89. package/dist/cli/commands/admin/token.js +126 -0
  90. package/dist/cli/commands/admin/token.js.map +1 -0
  91. package/dist/cli/commands/admin/transfer-ownership.d.ts +15 -0
  92. package/dist/cli/commands/admin/transfer-ownership.js +106 -0
  93. package/dist/cli/commands/admin/transfer-ownership.js.map +1 -0
  94. package/dist/cli/commands/admin/user.d.ts +11 -0
  95. package/dist/cli/commands/admin/user.js +187 -0
  96. package/dist/cli/commands/admin/user.js.map +1 -0
  97. package/dist/cli/commands/clone.d.ts +25 -3
  98. package/dist/cli/commands/clone.js +142 -36
  99. package/dist/cli/commands/clone.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 +5 -1
  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 +54 -6
  113. package/dist/cli/commands/mcp.js.map +1 -1
  114. package/dist/cli/commands/r2.d.ts +3 -0
  115. package/dist/cli/commands/r2.js +223 -204
  116. package/dist/cli/commands/r2.js.map +1 -1
  117. package/dist/cli/commands/redeploy.js +65 -12
  118. package/dist/cli/commands/redeploy.js.map +1 -1
  119. package/dist/cli/commands/reset.d.ts +22 -6
  120. package/dist/cli/commands/reset.js +132 -34
  121. package/dist/cli/commands/reset.js.map +1 -1
  122. package/dist/cli/commands/serve.js +231 -26
  123. package/dist/cli/commands/serve.js.map +1 -1
  124. package/dist/cli/commands/sync.d.ts +1 -1
  125. package/dist/cli/commands/sync.js +237 -227
  126. package/dist/cli/commands/sync.js.map +1 -1
  127. package/dist/cli/crypto-bundle.d.ts +39 -0
  128. package/dist/cli/crypto-bundle.js +94 -0
  129. package/dist/cli/crypto-bundle.js.map +1 -0
  130. package/dist/cli/index.js +24 -26
  131. package/dist/cli/index.js.map +1 -1
  132. package/dist/cli/railway-pin.d.ts +68 -0
  133. package/dist/cli/railway-pin.js +96 -0
  134. package/dist/cli/railway-pin.js.map +1 -0
  135. package/dist/cli/railway-provisioning.d.ts +53 -0
  136. package/dist/cli/railway-provisioning.js +85 -0
  137. package/dist/cli/railway-provisioning.js.map +1 -0
  138. package/dist/cli/setup-wizard.d.ts +30 -49
  139. package/dist/cli/setup-wizard.js +39 -40
  140. package/dist/cli/setup-wizard.js.map +1 -1
  141. package/dist/core/accent-palette.d.ts +22 -0
  142. package/dist/core/accent-palette.js +47 -0
  143. package/dist/core/accent-palette.js.map +1 -0
  144. package/dist/core/artifact-asset-urls.d.ts +51 -0
  145. package/dist/core/artifact-asset-urls.js +79 -0
  146. package/dist/core/artifact-asset-urls.js.map +1 -0
  147. package/dist/core/artifact-bundle.d.ts +69 -0
  148. package/dist/core/artifact-bundle.js +305 -0
  149. package/dist/core/artifact-bundle.js.map +1 -0
  150. package/dist/core/artifact-escape.d.ts +5 -0
  151. package/dist/core/artifact-escape.js +26 -0
  152. package/dist/core/artifact-escape.js.map +1 -0
  153. package/dist/core/artifact-export.d.ts +17 -0
  154. package/dist/core/artifact-export.js +529 -0
  155. package/dist/core/artifact-export.js.map +1 -0
  156. package/dist/core/cell-anchor.d.ts +29 -0
  157. package/dist/core/cell-anchor.js +53 -0
  158. package/dist/core/cell-anchor.js.map +1 -0
  159. package/dist/core/comment-anchor.d.ts +41 -0
  160. package/dist/core/comment-anchor.js +147 -0
  161. package/dist/core/comment-anchor.js.map +1 -0
  162. package/dist/core/commit-messages.d.ts +57 -0
  163. package/dist/core/commit-messages.js +85 -0
  164. package/dist/core/commit-messages.js.map +1 -0
  165. package/dist/core/embeds.d.ts +96 -0
  166. package/dist/core/embeds.js +187 -0
  167. package/dist/core/embeds.js.map +1 -0
  168. package/dist/core/entity-body-templates.d.ts +21 -0
  169. package/dist/core/entity-body-templates.js +67 -0
  170. package/dist/core/entity-body-templates.js.map +1 -0
  171. package/dist/core/entity-types-catalog.d.ts +65 -0
  172. package/dist/core/entity-types-catalog.js +142 -0
  173. package/dist/core/entity-types-catalog.js.map +1 -0
  174. package/dist/core/field-library.d.ts +62 -0
  175. package/dist/core/field-library.js +129 -0
  176. package/dist/core/field-library.js.map +1 -0
  177. package/dist/core/folder-paths.d.ts +41 -0
  178. package/dist/core/folder-paths.js +95 -0
  179. package/dist/core/folder-paths.js.map +1 -0
  180. package/dist/core/folder-sidecar.d.ts +36 -0
  181. package/dist/core/folder-sidecar.js +91 -0
  182. package/dist/core/folder-sidecar.js.map +1 -0
  183. package/dist/core/format-registry.d.ts +170 -0
  184. package/dist/core/format-registry.js +412 -0
  185. package/dist/core/format-registry.js.map +1 -0
  186. package/dist/core/graph.d.ts +773 -14
  187. package/dist/core/graph.js +2269 -308
  188. package/dist/core/graph.js.map +1 -1
  189. package/dist/core/history-diff.d.ts +35 -0
  190. package/dist/core/history-diff.js +114 -0
  191. package/dist/core/history-diff.js.map +1 -0
  192. package/dist/core/refs.d.ts +41 -0
  193. package/dist/core/refs.js +80 -0
  194. package/dist/core/refs.js.map +1 -0
  195. package/dist/core/schema-registry.d.ts +66 -0
  196. package/dist/core/schema-registry.js +198 -37
  197. package/dist/core/schema-registry.js.map +1 -1
  198. package/dist/core/schemas/connector.d.ts +67 -0
  199. package/dist/core/schemas/connector.js +100 -0
  200. package/dist/core/schemas/connector.js.map +1 -0
  201. package/dist/core/schemas/workspace.d.ts +122 -0
  202. package/dist/core/schemas/workspace.js +211 -0
  203. package/dist/core/schemas/workspace.js.map +1 -0
  204. package/dist/core/secrets/SecretStore.d.ts +77 -0
  205. package/dist/core/secrets/SecretStore.js +13 -0
  206. package/dist/core/secrets/SecretStore.js.map +1 -0
  207. package/dist/core/secrets/SettingsResolver.d.ts +54 -0
  208. package/dist/core/secrets/SettingsResolver.js +80 -0
  209. package/dist/core/secrets/SettingsResolver.js.map +1 -0
  210. package/dist/core/secrets/SettingsStore.d.ts +30 -0
  211. package/dist/core/secrets/SettingsStore.js +9 -0
  212. package/dist/core/secrets/SettingsStore.js.map +1 -0
  213. package/dist/core/secrets/SqliteEncryptedStore.d.ts +90 -0
  214. package/dist/core/secrets/SqliteEncryptedStore.js +598 -0
  215. package/dist/core/secrets/SqliteEncryptedStore.js.map +1 -0
  216. package/dist/core/secrets/audit.d.ts +36 -0
  217. package/dist/core/secrets/audit.js +60 -0
  218. package/dist/core/secrets/audit.js.map +1 -0
  219. package/dist/core/secrets/auth-db-migration.d.ts +129 -0
  220. package/dist/core/secrets/auth-db-migration.js +653 -0
  221. package/dist/core/secrets/auth-db-migration.js.map +1 -0
  222. package/dist/core/secrets/bundle.d.ts +141 -0
  223. package/dist/core/secrets/bundle.js +435 -0
  224. package/dist/core/secrets/bundle.js.map +1 -0
  225. package/dist/core/secrets/dual-write.d.ts +81 -0
  226. package/dist/core/secrets/dual-write.js +247 -0
  227. package/dist/core/secrets/dual-write.js.map +1 -0
  228. package/dist/core/secrets/encryption.d.ts +44 -0
  229. package/dist/core/secrets/encryption.js +97 -0
  230. package/dist/core/secrets/encryption.js.map +1 -0
  231. package/dist/core/secrets/index.d.ts +49 -0
  232. package/dist/core/secrets/index.js +74 -0
  233. package/dist/core/secrets/index.js.map +1 -0
  234. package/dist/core/secrets/master-key.d.ts +38 -0
  235. package/dist/core/secrets/master-key.js +122 -0
  236. package/dist/core/secrets/master-key.js.map +1 -0
  237. package/dist/core/secrets/probes/anthropic.d.ts +98 -0
  238. package/dist/core/secrets/probes/anthropic.js +300 -0
  239. package/dist/core/secrets/probes/anthropic.js.map +1 -0
  240. package/dist/core/secrets/probes/brave.d.ts +9 -0
  241. package/dist/core/secrets/probes/brave.js +15 -0
  242. package/dist/core/secrets/probes/brave.js.map +1 -0
  243. package/dist/core/secrets/probes/r2.d.ts +15 -0
  244. package/dist/core/secrets/probes/r2.js +14 -0
  245. package/dist/core/secrets/probes/r2.js.map +1 -0
  246. package/dist/core/secrets/probes/tavily.d.ts +18 -0
  247. package/dist/core/secrets/probes/tavily.js +58 -0
  248. package/dist/core/secrets/probes/tavily.js.map +1 -0
  249. package/dist/core/secrets/probes/voyage.d.ts +13 -0
  250. package/dist/core/secrets/probes/voyage.js +52 -0
  251. package/dist/core/secrets/probes/voyage.js.map +1 -0
  252. package/dist/core/secrets/r2-structural-migration.d.ts +39 -0
  253. package/dist/core/secrets/r2-structural-migration.js +109 -0
  254. package/dist/core/secrets/r2-structural-migration.js.map +1 -0
  255. package/dist/core/secrets/read-flip.d.ts +59 -0
  256. package/dist/core/secrets/read-flip.js +87 -0
  257. package/dist/core/secrets/read-flip.js.map +1 -0
  258. package/dist/core/secrets/registry.d.ts +188 -0
  259. package/dist/core/secrets/registry.js +616 -0
  260. package/dist/core/secrets/registry.js.map +1 -0
  261. package/dist/core/types.d.ts +77 -483
  262. package/dist/core/types.js +25 -3
  263. package/dist/core/types.js.map +1 -1
  264. package/dist/core/user-config.d.ts +75 -12
  265. package/dist/core/user-config.js +155 -14
  266. package/dist/core/user-config.js.map +1 -1
  267. package/dist/core/validation.d.ts +786 -1373
  268. package/dist/core/validation.js +458 -147
  269. package/dist/core/validation.js.map +1 -1
  270. package/dist/core/workspace-manager.d.ts +87 -14
  271. package/dist/core/workspace-manager.js +222 -94
  272. package/dist/core/workspace-manager.js.map +1 -1
  273. package/dist/core/workspace.d.ts +19 -5
  274. package/dist/core/workspace.js +67 -39
  275. package/dist/core/workspace.js.map +1 -1
  276. package/dist/mcp/comment-virtual-entity.d.ts +36 -0
  277. package/dist/mcp/comment-virtual-entity.js +71 -0
  278. package/dist/mcp/comment-virtual-entity.js.map +1 -0
  279. package/dist/mcp/connector-manager.d.ts +72 -2
  280. package/dist/mcp/connector-manager.js +211 -32
  281. package/dist/mcp/connector-manager.js.map +1 -1
  282. package/dist/mcp/oauth-provider.d.ts +0 -5
  283. package/dist/mcp/oauth-provider.js +10 -22
  284. package/dist/mcp/oauth-provider.js.map +1 -1
  285. package/dist/mcp/oauth-registry.d.ts +46 -8
  286. package/dist/mcp/oauth-registry.js +52 -13
  287. package/dist/mcp/oauth-registry.js.map +1 -1
  288. package/dist/mcp/server-discovery.d.ts +73 -0
  289. package/dist/mcp/server-discovery.js +164 -0
  290. package/dist/mcp/server-discovery.js.map +1 -0
  291. package/dist/mcp/server.d.ts +24 -3
  292. package/dist/mcp/server.js +53 -10
  293. package/dist/mcp/server.js.map +1 -1
  294. package/dist/mcp/state-signer.d.ts +62 -0
  295. package/dist/mcp/state-signer.js +205 -0
  296. package/dist/mcp/state-signer.js.map +1 -0
  297. package/dist/mcp/stdio-proxy.d.ts +67 -0
  298. package/dist/mcp/stdio-proxy.js +149 -0
  299. package/dist/mcp/stdio-proxy.js.map +1 -0
  300. package/dist/mcp/tools.d.ts +65 -2
  301. package/dist/mcp/tools.js +1772 -124
  302. package/dist/mcp/tools.js.map +1 -1
  303. package/dist/server/__tests__/helpers/graph-api.d.ts +16 -0
  304. package/dist/server/__tests__/helpers/graph-api.js +16 -0
  305. package/dist/server/__tests__/helpers/graph-api.js.map +1 -0
  306. package/dist/server/artifact-asset-mint.d.ts +41 -0
  307. package/dist/server/artifact-asset-mint.js +59 -0
  308. package/dist/server/artifact-asset-mint.js.map +1 -0
  309. package/dist/server/asset-index-queue.d.ts +98 -0
  310. package/dist/server/asset-index-queue.js +193 -0
  311. package/dist/server/asset-index-queue.js.map +1 -0
  312. package/dist/server/body-write-coordinator.d.ts +161 -0
  313. package/dist/server/body-write-coordinator.js +182 -0
  314. package/dist/server/body-write-coordinator.js.map +1 -0
  315. package/dist/server/cloud-billing-flow.d.ts +32 -0
  316. package/dist/server/cloud-billing-flow.js +75 -0
  317. package/dist/server/cloud-billing-flow.js.map +1 -0
  318. package/dist/server/commit-audit.d.ts +26 -0
  319. package/dist/server/commit-audit.js +30 -0
  320. package/dist/server/commit-audit.js.map +1 -0
  321. package/dist/server/index.d.ts +29 -3
  322. package/dist/server/index.js +657 -269
  323. package/dist/server/index.js.map +1 -1
  324. package/dist/server/middleware/sanitize.d.ts +46 -0
  325. package/dist/server/middleware/sanitize.js +182 -0
  326. package/dist/server/middleware/sanitize.js.map +1 -0
  327. package/dist/server/routes/artifact-export-api.d.ts +11 -0
  328. package/dist/server/routes/artifact-export-api.js +159 -0
  329. package/dist/server/routes/artifact-export-api.js.map +1 -0
  330. package/dist/server/routes/asset-api.d.ts +76 -1
  331. package/dist/server/routes/asset-api.js +761 -61
  332. package/dist/server/routes/asset-api.js.map +1 -1
  333. package/dist/server/routes/audit-api.d.ts +24 -0
  334. package/dist/server/routes/audit-api.js +117 -0
  335. package/dist/server/routes/audit-api.js.map +1 -0
  336. package/dist/server/routes/auth-api.js +532 -63
  337. package/dist/server/routes/auth-api.js.map +1 -1
  338. package/dist/server/routes/capture-api.d.ts +10 -3
  339. package/dist/server/routes/capture-api.js +213 -25
  340. package/dist/server/routes/capture-api.js.map +1 -1
  341. package/dist/server/routes/chat-sessions-api.d.ts +13 -0
  342. package/dist/server/routes/chat-sessions-api.js +455 -0
  343. package/dist/server/routes/chat-sessions-api.js.map +1 -0
  344. package/dist/server/routes/chat.d.ts +35 -1
  345. package/dist/server/routes/chat.js +699 -54
  346. package/dist/server/routes/chat.js.map +1 -1
  347. package/dist/server/routes/comments-api.d.ts +15 -0
  348. package/dist/server/routes/comments-api.js +444 -0
  349. package/dist/server/routes/comments-api.js.map +1 -0
  350. package/dist/server/routes/config-api.d.ts +3 -2
  351. package/dist/server/routes/config-api.js +179 -49
  352. package/dist/server/routes/config-api.js.map +1 -1
  353. package/dist/server/routes/connectors-api.js +177 -42
  354. package/dist/server/routes/connectors-api.js.map +1 -1
  355. package/dist/server/routes/event-ingest-api.d.ts +15 -0
  356. package/dist/server/routes/{meeting-ingest-api.js → event-ingest-api.js} +35 -20
  357. package/dist/server/routes/event-ingest-api.js.map +1 -0
  358. package/dist/server/routes/field-library-api.d.ts +28 -0
  359. package/dist/server/routes/field-library-api.js +126 -0
  360. package/dist/server/routes/field-library-api.js.map +1 -0
  361. package/dist/server/routes/git-http.d.ts +2 -2
  362. package/dist/server/routes/git-http.js +86 -39
  363. package/dist/server/routes/git-http.js.map +1 -1
  364. package/dist/server/routes/graph-api-access.d.ts +57 -0
  365. package/dist/server/routes/graph-api-access.js +112 -0
  366. package/dist/server/routes/graph-api-access.js.map +1 -0
  367. package/dist/server/routes/graph-api.d.ts +1 -1
  368. package/dist/server/routes/graph-api.js +1455 -325
  369. package/dist/server/routes/graph-api.js.map +1 -1
  370. package/dist/server/routes/health.d.ts +18 -0
  371. package/dist/server/routes/health.js +74 -0
  372. package/dist/server/routes/health.js.map +1 -0
  373. package/dist/server/routes/import-batch.d.ts +24 -0
  374. package/dist/server/routes/import-batch.js +33 -0
  375. package/dist/server/routes/import-batch.js.map +1 -0
  376. package/dist/server/routes/insights-api.d.ts +9 -2
  377. package/dist/server/routes/insights-api.js +355 -61
  378. package/dist/server/routes/insights-api.js.map +1 -1
  379. package/dist/server/routes/mcp.d.ts +7 -3
  380. package/dist/server/routes/mcp.js +24 -8
  381. package/dist/server/routes/mcp.js.map +1 -1
  382. package/dist/server/routes/oauth-api.d.ts +67 -0
  383. package/dist/server/routes/oauth-api.js +421 -0
  384. package/dist/server/routes/oauth-api.js.map +1 -0
  385. package/dist/server/routes/permissions-api.d.ts +9 -2
  386. package/dist/server/routes/permissions-api.js +87 -31
  387. package/dist/server/routes/permissions-api.js.map +1 -1
  388. package/dist/server/routes/r2-api.d.ts +25 -0
  389. package/dist/server/routes/r2-api.js +276 -0
  390. package/dist/server/routes/r2-api.js.map +1 -0
  391. package/dist/server/routes/secrets-api.d.ts +36 -0
  392. package/dist/server/routes/secrets-api.js +308 -0
  393. package/dist/server/routes/secrets-api.js.map +1 -0
  394. package/dist/server/routes/settings-api.d.ts +29 -0
  395. package/dist/server/routes/settings-api.js +180 -0
  396. package/dist/server/routes/settings-api.js.map +1 -0
  397. package/dist/server/routes/share-api.d.ts +32 -0
  398. package/dist/server/routes/share-api.js +234 -0
  399. package/dist/server/routes/share-api.js.map +1 -0
  400. package/dist/server/routes/views-api.js +51 -0
  401. package/dist/server/routes/views-api.js.map +1 -1
  402. package/dist/server/routes/workspace-api.d.ts +2 -1
  403. package/dist/server/routes/workspace-api.js +142 -23
  404. package/dist/server/routes/workspace-api.js.map +1 -1
  405. package/dist/server/routes/ws.d.ts +3 -2
  406. package/dist/server/routes/ws.js +68 -17
  407. package/dist/server/routes/ws.js.map +1 -1
  408. package/dist/server/session-manager.d.ts +48 -5
  409. package/dist/server/session-manager.js +182 -14
  410. package/dist/server/session-manager.js.map +1 -1
  411. package/dist/server/ws-hub.d.ts +124 -37
  412. package/dist/server/ws-hub.js +0 -0
  413. package/dist/server/ws-hub.js.map +1 -1
  414. package/dist/server/yjs-manager.d.ts +278 -7
  415. package/dist/server/yjs-manager.js +830 -38
  416. package/dist/server/yjs-manager.js.map +1 -1
  417. package/dist/server/yjs-persistence.d.ts +165 -0
  418. package/dist/server/yjs-persistence.js +557 -0
  419. package/dist/server/yjs-persistence.js.map +1 -0
  420. package/dist/server/yjs-text-diff.d.ts +32 -0
  421. package/dist/server/yjs-text-diff.js +61 -0
  422. package/dist/server/yjs-text-diff.js.map +1 -0
  423. package/dist/services/access-control.d.ts +73 -0
  424. package/dist/services/access-control.js +165 -0
  425. package/dist/services/access-control.js.map +1 -0
  426. package/dist/services/artifact-asset-token.d.ts +69 -0
  427. package/dist/services/artifact-asset-token.js +94 -0
  428. package/dist/services/artifact-asset-token.js.map +1 -0
  429. package/dist/services/artifact-library-sources.d.ts +3 -0
  430. package/dist/services/artifact-library-sources.js +44 -0
  431. package/dist/services/artifact-library-sources.js.map +1 -0
  432. package/dist/services/artifact-render-export.d.ts +97 -0
  433. package/dist/services/artifact-render-export.js +467 -0
  434. package/dist/services/artifact-render-export.js.map +1 -0
  435. package/dist/services/asset-caption-service.d.ts +103 -0
  436. package/dist/services/asset-caption-service.js +186 -0
  437. package/dist/services/asset-caption-service.js.map +1 -0
  438. package/dist/services/asset-delete-authz.d.ts +31 -0
  439. package/dist/services/asset-delete-authz.js +61 -0
  440. package/dist/services/asset-delete-authz.js.map +1 -0
  441. package/dist/services/asset-finalize-service.d.ts +58 -0
  442. package/dist/services/asset-finalize-service.js +216 -0
  443. package/dist/services/asset-finalize-service.js.map +1 -0
  444. package/dist/services/asset-import-service.d.ts +111 -0
  445. package/dist/services/asset-import-service.js +394 -0
  446. package/dist/services/asset-import-service.js.map +1 -0
  447. package/dist/services/asset-index-sweep.d.ts +95 -0
  448. package/dist/services/asset-index-sweep.js +249 -0
  449. package/dist/services/asset-index-sweep.js.map +1 -0
  450. package/dist/services/asset-lifecycle-check.d.ts +22 -0
  451. package/dist/services/asset-lifecycle-check.js +80 -0
  452. package/dist/services/asset-lifecycle-check.js.map +1 -0
  453. package/dist/services/asset-listing.d.ts +72 -0
  454. package/dist/services/asset-listing.js +321 -0
  455. package/dist/services/asset-listing.js.map +1 -0
  456. package/dist/services/asset-presign-service.d.ts +68 -0
  457. package/dist/services/asset-presign-service.js +124 -0
  458. package/dist/services/asset-presign-service.js.map +1 -0
  459. package/dist/services/asset-sidecar-service.d.ts +28 -0
  460. package/dist/services/asset-sidecar-service.js +79 -0
  461. package/dist/services/asset-sidecar-service.js.map +1 -0
  462. package/dist/services/asset-upload-errors.d.ts +41 -0
  463. package/dist/services/asset-upload-errors.js +45 -0
  464. package/dist/services/asset-upload-errors.js.map +1 -0
  465. package/dist/services/asset-upload-service.d.ts +56 -0
  466. package/dist/services/asset-upload-service.js +200 -0
  467. package/dist/services/asset-upload-service.js.map +1 -0
  468. package/dist/services/asset-upload-token.d.ts +58 -0
  469. package/dist/services/asset-upload-token.js +75 -0
  470. package/dist/services/asset-upload-token.js.map +1 -0
  471. package/dist/services/assets/asset-index-service.d.ts +127 -0
  472. package/dist/services/assets/asset-index-service.js +227 -0
  473. package/dist/services/assets/asset-index-service.js.map +1 -0
  474. package/dist/services/assets/base.d.ts +128 -2
  475. package/dist/services/assets/base.js +98 -1
  476. package/dist/services/assets/base.js.map +1 -1
  477. package/dist/services/assets/index.d.ts +114 -1
  478. package/dist/services/assets/index.js +221 -0
  479. package/dist/services/assets/index.js.map +1 -1
  480. package/dist/services/assets/local.d.ts +16 -1
  481. package/dist/services/assets/local.js +110 -1
  482. package/dist/services/assets/local.js.map +1 -1
  483. package/dist/services/assets/r2-sync.d.ts +88 -0
  484. package/dist/services/assets/r2-sync.js +412 -0
  485. package/dist/services/assets/r2-sync.js.map +1 -0
  486. package/dist/services/assets/r2.d.ts +87 -1
  487. package/dist/services/assets/r2.js +203 -1
  488. package/dist/services/assets/r2.js.map +1 -1
  489. package/dist/services/auth-service.d.ts +312 -45
  490. package/dist/services/auth-service.js +1011 -195
  491. package/dist/services/auth-service.js.map +1 -1
  492. package/dist/services/chat-session-service.d.ts +188 -0
  493. package/dist/services/chat-session-service.js +512 -0
  494. package/dist/services/chat-session-service.js.map +1 -0
  495. package/dist/services/cloud-inference-billing.d.ts +64 -0
  496. package/dist/services/cloud-inference-billing.js +313 -0
  497. package/dist/services/cloud-inference-billing.js.map +1 -0
  498. package/dist/services/comment-service.d.ts +97 -0
  499. package/dist/services/comment-service.js +341 -0
  500. package/dist/services/comment-service.js.map +1 -0
  501. package/dist/services/comment-virtual-entity.d.ts +36 -0
  502. package/dist/services/comment-virtual-entity.js +71 -0
  503. package/dist/services/comment-virtual-entity.js.map +1 -0
  504. package/dist/services/convert-service.d.ts +64 -0
  505. package/dist/services/convert-service.js +68 -0
  506. package/dist/services/convert-service.js.map +1 -0
  507. package/dist/services/csv-service.d.ts +22 -17
  508. package/dist/services/csv-service.js +55 -92
  509. package/dist/services/csv-service.js.map +1 -1
  510. package/dist/services/entity-mutations.d.ts +213 -0
  511. package/dist/services/entity-mutations.js +380 -0
  512. package/dist/services/entity-mutations.js.map +1 -0
  513. package/dist/services/{meeting-processor.d.ts → event-processor.d.ts} +15 -8
  514. package/dist/services/{meeting-processor.js → event-processor.js} +124 -65
  515. package/dist/services/event-processor.js.map +1 -0
  516. package/dist/services/extract/docx.d.ts +1 -0
  517. package/dist/services/extract/docx.js +233 -0
  518. package/dist/services/extract/docx.js.map +1 -0
  519. package/dist/services/extract/index.d.ts +9 -0
  520. package/dist/services/extract/index.js +10 -0
  521. package/dist/services/extract/index.js.map +1 -0
  522. package/dist/services/extract/pdf.d.ts +13 -0
  523. package/dist/services/extract/pdf.js +69 -0
  524. package/dist/services/extract/pdf.js.map +1 -0
  525. package/dist/services/folder-creation.d.ts +33 -0
  526. package/dist/services/folder-creation.js +103 -0
  527. package/dist/services/folder-creation.js.map +1 -0
  528. package/dist/services/git.d.ts +58 -0
  529. package/dist/services/git.js +194 -55
  530. package/dist/services/git.js.map +1 -1
  531. package/dist/services/graph-maintenance.js +4 -4
  532. package/dist/services/graph-maintenance.js.map +1 -1
  533. package/dist/services/import-service.d.ts +51 -2
  534. package/dist/services/import-service.js +254 -107
  535. package/dist/services/import-service.js.map +1 -1
  536. package/dist/services/lint-service.js +10 -17
  537. package/dist/services/lint-service.js.map +1 -1
  538. package/dist/services/markdown.d.ts +12 -1
  539. package/dist/services/markdown.js +77 -9
  540. package/dist/services/markdown.js.map +1 -1
  541. package/dist/services/mention-detector.d.ts +23 -0
  542. package/dist/services/mention-detector.js +47 -0
  543. package/dist/services/mention-detector.js.map +1 -0
  544. package/dist/services/model-capabilities.d.ts +41 -0
  545. package/dist/services/model-capabilities.js +107 -0
  546. package/dist/services/model-capabilities.js.map +1 -0
  547. package/dist/services/notification-broadcast.d.ts +32 -0
  548. package/dist/services/notification-broadcast.js +38 -0
  549. package/dist/services/notification-broadcast.js.map +1 -0
  550. package/dist/services/notification-service.d.ts +41 -0
  551. package/dist/services/notification-service.js +100 -0
  552. package/dist/services/notification-service.js.map +1 -0
  553. package/dist/services/oauth-server-store.d.ts +147 -0
  554. package/dist/services/oauth-server-store.js +319 -0
  555. package/dist/services/oauth-server-store.js.map +1 -0
  556. package/dist/services/orphan-service.js +2 -2
  557. package/dist/services/orphan-service.js.map +1 -1
  558. package/dist/services/personal-folder.d.ts +40 -0
  559. package/dist/services/personal-folder.js +101 -0
  560. package/dist/services/personal-folder.js.map +1 -0
  561. package/dist/services/scratch-asset-promote.d.ts +57 -0
  562. package/dist/services/scratch-asset-promote.js +95 -0
  563. package/dist/services/scratch-asset-promote.js.map +1 -0
  564. package/dist/services/scratch-attachment-service.d.ts +196 -0
  565. package/dist/services/scratch-attachment-service.js +347 -0
  566. package/dist/services/scratch-attachment-service.js.map +1 -0
  567. package/dist/services/share-link-service.d.ts +57 -0
  568. package/dist/services/share-link-service.js +142 -0
  569. package/dist/services/share-link-service.js.map +1 -0
  570. package/dist/services/user-person-bridge.d.ts +136 -0
  571. package/dist/services/user-person-bridge.js +340 -0
  572. package/dist/services/user-person-bridge.js.map +1 -0
  573. package/dist/services/vector-service.d.ts +282 -5
  574. package/dist/services/vector-service.js +585 -29
  575. package/dist/services/vector-service.js.map +1 -1
  576. package/dist/services/workspace-access.d.ts +108 -0
  577. package/dist/services/workspace-access.js +149 -0
  578. package/dist/services/workspace-access.js.map +1 -0
  579. package/dist/services/workspace-assets-folder.d.ts +46 -0
  580. package/dist/services/workspace-assets-folder.js +75 -0
  581. package/dist/services/workspace-assets-folder.js.map +1 -0
  582. package/dist/utils/git.d.ts +17 -2
  583. package/dist/utils/git.js +23 -7
  584. package/dist/utils/git.js.map +1 -1
  585. package/dist/utils/log.d.ts +42 -0
  586. package/dist/utils/log.js +137 -0
  587. package/dist/utils/log.js.map +1 -0
  588. package/dist/utils/preflight.js +2 -2
  589. package/dist/utils/preflight.js.map +1 -1
  590. package/dist/utils/version-checker.d.ts +12 -19
  591. package/dist/utils/version-checker.js +14 -104
  592. package/dist/utils/version-checker.js.map +1 -1
  593. package/dist/web/_app/immutable/assets/0.Bs_z3Z86.css +2 -0
  594. package/dist/web/_app/immutable/assets/12.DlIf1mKh.css +1 -0
  595. package/dist/web/_app/immutable/assets/14.7d-Q37wc.css +1 -0
  596. package/dist/web/_app/immutable/assets/15.BwBFdc1D.css +1 -0
  597. package/dist/web/_app/immutable/assets/16.CmLrbzZp.css +1 -0
  598. package/dist/web/_app/immutable/assets/17.DcuK5ZVm.css +1 -0
  599. package/dist/web/_app/immutable/assets/2.DATnR31c.css +1 -0
  600. package/dist/web/_app/immutable/assets/3.Cz4H8n_O.css +1 -0
  601. package/dist/web/_app/immutable/assets/4.aDHteTDy.css +1 -0
  602. package/dist/web/_app/immutable/assets/6.CF1i5Bon.css +1 -0
  603. package/dist/web/_app/immutable/assets/ArtifactEntityView.C4ooklFD.css +1 -0
  604. package/dist/web/_app/immutable/assets/AssetLibraryPicker.BOFW0MvF.css +1 -0
  605. package/dist/web/_app/immutable/assets/BulkActionsBar.cmKvHzRK.css +1 -0
  606. package/dist/web/_app/immutable/assets/CalendarView.lBCvS3X5.css +1 -0
  607. package/dist/web/_app/immutable/assets/CodeMirrorEditor.D6hf2pNJ.css +1 -0
  608. package/dist/web/_app/immutable/assets/CopyField.DVGZtw4U.css +1 -0
  609. package/dist/web/_app/immutable/assets/FolderMembersPanel.kiYnDFHu.css +1 -0
  610. package/dist/web/_app/immutable/assets/FolderMutationDialogs.BMRF6Z3z.css +1 -0
  611. package/dist/web/_app/immutable/assets/FolderPicker.Cp46GHe2.css +1 -0
  612. package/dist/web/_app/immutable/assets/GalleryView.WBGxxUQc.css +1 -0
  613. package/dist/web/_app/immutable/assets/GraphView.BJtGL9py.css +1 -0
  614. package/dist/web/_app/immutable/assets/KanbanView.BOaI5KFL.css +1 -0
  615. package/dist/web/_app/immutable/assets/Link.vV0ne105.css +1 -0
  616. package/dist/web/_app/immutable/assets/Markdown.BZnsSOSf.css +1 -0
  617. package/dist/web/_app/immutable/assets/Rail.ilusFgD4.css +1 -0
  618. package/dist/web/_app/immutable/assets/SettingsDialog.D7-BO6S_.css +1 -0
  619. package/dist/web/_app/immutable/assets/Spinner.UBfl0pab.css +1 -0
  620. package/dist/web/_app/immutable/assets/StopFilledAlt.fNlDN65D.css +1 -0
  621. package/dist/web/_app/immutable/assets/TimelineView.xAZBJhHw.css +1 -0
  622. package/dist/web/_app/immutable/assets/WorkspaceView.C8ErUJAY.css +1 -0
  623. package/dist/web/_app/immutable/assets/button.BlLm4Dg1.css +1 -0
  624. package/dist/web/_app/immutable/assets/history-summary.LpmHXasl.css +1 -0
  625. package/dist/web/_app/immutable/assets/ibm-plex-mono-latin-400-normal.CvHOgSBP.woff +0 -0
  626. package/dist/web/_app/immutable/assets/ibm-plex-mono-latin-400-normal.DMJ8VG8y.woff2 +0 -0
  627. package/dist/web/_app/immutable/assets/ibm-plex-mono-latin-500-normal.CB9ihrfo.woff +0 -0
  628. package/dist/web/_app/immutable/assets/ibm-plex-mono-latin-500-normal.DSY6xOcd.woff2 +0 -0
  629. package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-400-italic.CZTNEAuW.woff2 +0 -0
  630. package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-400-italic.CsGl1sm0.woff +0 -0
  631. package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-400-normal.CDDApCn2.woff2 +0 -0
  632. package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-400-normal.CYLoc0-x.woff +0 -0
  633. package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-500-italic.BNK2_mGO.woff2 +0 -0
  634. package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-500-italic.DpEwFAQM.woff +0 -0
  635. package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-500-normal.6ng42L7E.woff2 +0 -0
  636. package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-500-normal.BgVn5rGT.woff +0 -0
  637. package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-600-normal.Cu4Hd6ag.woff +0 -0
  638. package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-600-normal.CuJfVYMP.woff2 +0 -0
  639. package/dist/web/_app/immutable/assets/inline-list.cqga56xT.css +1 -0
  640. package/dist/web/_app/immutable/assets/list-columns.BgnafZ4K.css +1 -0
  641. package/dist/web/_app/immutable/assets/realtime.DDCWxn3B.css +1 -0
  642. package/dist/web/_app/immutable/assets/select.B90KUqR4.css +1 -0
  643. package/dist/web/_app/immutable/assets/sheet.RF9RBmml.css +1 -0
  644. package/dist/web/_app/immutable/chunks/22qYtvr82.js +1 -0
  645. package/dist/web/_app/immutable/chunks/3anKjXOb.js +1 -0
  646. package/dist/web/_app/immutable/chunks/6S9WOky52.js +1 -0
  647. package/dist/web/_app/immutable/chunks/7xJ91z7d.js +2 -0
  648. package/dist/web/_app/immutable/chunks/9K8VREGP.js +1 -0
  649. package/dist/web/_app/immutable/chunks/B-TGZnCt2.js +1 -0
  650. package/dist/web/_app/immutable/chunks/B-n5q6ku.js +1 -0
  651. package/dist/web/_app/immutable/chunks/B0p2fSEV2.js +1 -0
  652. package/dist/web/_app/immutable/chunks/B2oi87jG.js +2 -0
  653. package/dist/web/_app/immutable/chunks/B4DG5QOL.js +1 -0
  654. package/dist/web/_app/immutable/chunks/B6Lt-qaJ.js +1 -0
  655. package/dist/web/_app/immutable/chunks/B6qeIQZz.js +2 -0
  656. package/dist/web/_app/immutable/chunks/B9KfEsQw2.js +1 -0
  657. package/dist/web/_app/immutable/chunks/B9VRF5aH2.js +1 -0
  658. package/dist/web/_app/immutable/chunks/BCQQzpbj.js +1 -0
  659. package/dist/web/_app/immutable/chunks/BF0vIqH9.js +1 -0
  660. package/dist/web/_app/immutable/chunks/BFp1K70l.js +8 -0
  661. package/dist/web/_app/immutable/chunks/BFr7Funh.js +1 -0
  662. package/dist/web/_app/immutable/chunks/BFx32D20.js +2 -0
  663. package/dist/web/_app/immutable/chunks/BLs1h0Rh2.js +1 -0
  664. package/dist/web/_app/immutable/chunks/BPzV_xOS2.js +1 -0
  665. package/dist/web/_app/immutable/chunks/BRvMjAzW.js +2 -0
  666. package/dist/web/_app/immutable/chunks/BV-iCKqa.js +1 -0
  667. package/dist/web/_app/immutable/chunks/BZi3uKF4.js +1 -0
  668. package/dist/web/_app/immutable/chunks/B_PyL87x.js +2 -0
  669. package/dist/web/_app/immutable/chunks/BbfHGU9n2.js +1 -0
  670. package/dist/web/_app/immutable/chunks/Bbqu2-5G2.js +1 -0
  671. package/dist/web/_app/immutable/chunks/BdWZJpmv.js +2 -0
  672. package/dist/web/_app/immutable/chunks/BfnFtsiY.js +1 -0
  673. package/dist/web/_app/immutable/chunks/BlgjVmFM2.js +14 -0
  674. package/dist/web/_app/immutable/chunks/BmgXvmIG.js +5 -0
  675. package/dist/web/_app/immutable/chunks/BpCG9m962.js +1 -0
  676. package/dist/web/_app/immutable/chunks/BrveeylZ.js +1 -0
  677. package/dist/web/_app/immutable/chunks/BsW72fyR2.js +1 -0
  678. package/dist/web/_app/immutable/chunks/Bti_XKwx.js +2692 -0
  679. package/dist/web/_app/immutable/chunks/BuhW5Fr0.js +689 -0
  680. package/dist/web/_app/immutable/chunks/Bx74EEyn2.js +1 -0
  681. package/dist/web/_app/immutable/chunks/BxPN0T61.js +1 -0
  682. package/dist/web/_app/immutable/chunks/Bxk27YWG.js +1 -0
  683. package/dist/web/_app/immutable/chunks/Bz9lrgGs.js +1 -0
  684. package/dist/web/_app/immutable/chunks/BzaxZ6j12.js +1 -0
  685. package/dist/web/_app/immutable/chunks/C1B4xDs0.js +1 -0
  686. package/dist/web/_app/immutable/chunks/C1trcC8M.js +7 -0
  687. package/dist/web/_app/immutable/chunks/C3F_NMTk.js +3 -0
  688. package/dist/web/_app/immutable/chunks/C4PZmGUQ.js +2 -0
  689. package/dist/web/_app/immutable/chunks/C6wYiSz8.js +1 -0
  690. package/dist/web/_app/immutable/chunks/C70saxIl.js +1 -0
  691. package/dist/web/_app/immutable/chunks/C9tvkk7Y2.js +22 -0
  692. package/dist/web/_app/immutable/chunks/CE1XZ5DE.js +1 -0
  693. package/dist/web/_app/immutable/chunks/CEahbtnP.js +1 -0
  694. package/dist/web/_app/immutable/chunks/CGkd0a-c2.js +2 -0
  695. package/dist/web/_app/immutable/chunks/CIoNEHQ82.js +184 -0
  696. package/dist/web/_app/immutable/chunks/CK4z74w5.js +1 -0
  697. package/dist/web/_app/immutable/chunks/COo3HgCZ.js +1 -0
  698. package/dist/web/_app/immutable/chunks/CP8_U450.js +1 -0
  699. package/dist/web/_app/immutable/chunks/CQ1eQ93t2.js +6 -0
  700. package/dist/web/_app/immutable/chunks/CQyBDccA.js +1 -0
  701. package/dist/web/_app/immutable/chunks/CUFQMEBn2.js +1 -0
  702. package/dist/web/_app/immutable/chunks/CV5OSCJc.js +1 -0
  703. package/dist/web/_app/immutable/chunks/CYEYDNwO2.js +1 -0
  704. package/dist/web/_app/immutable/chunks/CZgFaimi2.js +83 -0
  705. package/dist/web/_app/immutable/chunks/CZqvCTCv.js +1 -0
  706. package/dist/web/_app/immutable/chunks/CcWaWbrI2.js +1 -0
  707. package/dist/web/_app/immutable/chunks/CdtzRVZK.js +1 -0
  708. package/dist/web/_app/immutable/chunks/CfYG72Hc.js +1 -0
  709. package/dist/web/_app/immutable/chunks/Cg-ND0cl.js +1 -0
  710. package/dist/web/_app/immutable/chunks/Ch1kmm3J2.js +1 -0
  711. package/dist/web/_app/immutable/chunks/CjiMLTwX2.js +1 -0
  712. package/dist/web/_app/immutable/chunks/CkoeSJ8K.js +6 -0
  713. package/dist/web/_app/immutable/chunks/Cp87MaaW.js +1 -0
  714. package/dist/web/_app/immutable/chunks/Cqm5wAin.js +1 -0
  715. package/dist/web/_app/immutable/chunks/Ct1uesdW.js +2 -0
  716. package/dist/web/_app/immutable/chunks/CuYPXpWU.js +1 -0
  717. package/dist/web/_app/immutable/chunks/Cuq8J2Td.js +1 -0
  718. package/dist/web/_app/immutable/chunks/CvSEodTA.js +1 -0
  719. package/dist/web/_app/immutable/chunks/CvqqrTrj.js +1 -0
  720. package/dist/web/_app/immutable/chunks/CzXdOgVl2.js +7 -0
  721. package/dist/web/_app/immutable/chunks/D0W7c6Sg.js +185 -0
  722. package/dist/web/_app/immutable/chunks/D0rBhnqB2.js +1 -0
  723. package/dist/web/_app/immutable/chunks/D1-GZSN1.js +1 -0
  724. package/dist/web/_app/immutable/chunks/D1BLcNJ7.js +1 -0
  725. package/dist/web/_app/immutable/chunks/D1yvtriI.js +1 -0
  726. package/dist/web/_app/immutable/chunks/D25NMRpl.js +1 -0
  727. package/dist/web/_app/immutable/chunks/D3F675fu2.js +2 -0
  728. package/dist/web/_app/immutable/chunks/D4UrtGC1.js +1 -0
  729. package/dist/web/_app/immutable/chunks/D8wZ2xul.js +4 -0
  730. package/dist/web/_app/immutable/chunks/DA3HEX3X.js +1 -0
  731. package/dist/web/_app/immutable/chunks/DE2TCESD2.js +1 -0
  732. package/dist/web/_app/immutable/chunks/DN-gIRdd2.js +1 -0
  733. package/dist/web/_app/immutable/chunks/DPUVu0T0.js +1 -0
  734. package/dist/web/_app/immutable/chunks/DPcejqKZ.js +1 -0
  735. package/dist/web/_app/immutable/chunks/DQMWa2t7.js +1 -0
  736. package/dist/web/_app/immutable/chunks/DRPMJ-3M.js +2 -0
  737. package/dist/web/_app/immutable/chunks/DRPnwQAm.js +1 -0
  738. package/dist/web/_app/immutable/chunks/DXmfl4Hq2.js +1 -0
  739. package/dist/web/_app/immutable/chunks/DXsFrDer2.js +1 -0
  740. package/dist/web/_app/immutable/chunks/DYc5KW3F.js +1 -0
  741. package/dist/web/_app/immutable/chunks/DcPrzUMJ.js +1 -0
  742. package/dist/web/_app/immutable/chunks/DdQVGKgM.js +1 -0
  743. package/dist/web/_app/immutable/chunks/DiFfW--R2.js +2 -0
  744. package/dist/web/_app/immutable/chunks/DjIqa1_y.js +2 -0
  745. package/dist/web/_app/immutable/chunks/Dl63IUnH2.js +224 -0
  746. package/dist/web/_app/immutable/chunks/DmFdD6zT.js +1 -0
  747. package/dist/web/_app/immutable/chunks/Dn3R8S-U.js +1 -0
  748. package/dist/web/_app/immutable/chunks/DpNr1GQ2.js +1 -0
  749. package/dist/web/_app/immutable/chunks/DsFEd8vL2.js +1 -0
  750. package/dist/web/_app/immutable/chunks/DuxziNnw.js +1 -0
  751. package/dist/web/_app/immutable/chunks/FuiJnZG0.js +1 -0
  752. package/dist/web/_app/immutable/chunks/Izkulod7.js +1 -0
  753. package/dist/web/_app/immutable/chunks/LYL8znYR2.js +23 -0
  754. package/dist/web/_app/immutable/chunks/ON7Gta0d.js +1 -0
  755. package/dist/web/_app/immutable/chunks/PhN6tXuJ2.js +1 -0
  756. package/dist/web/_app/immutable/chunks/PyLyCpuL.js +1 -0
  757. package/dist/web/_app/immutable/chunks/RG7IUVLn2.js +2 -0
  758. package/dist/web/_app/immutable/chunks/RK4gCWN0.js +2 -0
  759. package/dist/web/_app/immutable/chunks/SnzlL4ZV.js +1 -0
  760. package/dist/web/_app/immutable/chunks/XcWejFm_.js +5 -0
  761. package/dist/web/_app/immutable/chunks/Xkp5hSA1.js +1 -0
  762. package/dist/web/_app/immutable/chunks/al_nidE9.js +1 -0
  763. package/dist/web/_app/immutable/chunks/e50-izZO2.js +1 -0
  764. package/dist/web/_app/immutable/chunks/golz20La2.js +2 -0
  765. package/dist/web/_app/immutable/chunks/hG-QCarB.js +1 -0
  766. package/dist/web/_app/immutable/chunks/kNaey6uv.js +1 -0
  767. package/dist/web/_app/immutable/chunks/nXReuy-5.js +3 -0
  768. package/dist/web/_app/immutable/chunks/o61aZZFZ2.js +66 -0
  769. package/dist/web/_app/immutable/chunks/qwr5boV8.js +1 -0
  770. package/dist/web/_app/immutable/chunks/rjjyuGhb.js +1 -0
  771. package/dist/web/_app/immutable/chunks/uBgoVlsx.js +3 -0
  772. package/dist/web/_app/immutable/chunks/xY0c-UfG.js +3 -0
  773. package/dist/web/_app/immutable/chunks/xeNvx4Bl2.js +1 -0
  774. package/dist/web/_app/immutable/chunks/xihTtKlq.js +1 -0
  775. package/dist/web/_app/immutable/entry/app.dqA31fjy.js +2 -0
  776. package/dist/web/_app/immutable/entry/start.BB1XrAea.js +1 -0
  777. package/dist/web/_app/immutable/nodes/0.dALEQAnu.js +2 -0
  778. package/dist/web/_app/immutable/nodes/1.aFU32Ukj.js +1 -0
  779. package/dist/web/_app/immutable/nodes/10.C2wB-OnW.js +1 -0
  780. package/dist/web/_app/immutable/nodes/11.CI3a8Rj5.js +1 -0
  781. package/dist/web/_app/immutable/nodes/12.CsTvsKzB.js +1 -0
  782. package/dist/web/_app/immutable/nodes/13.CYrHXCA8.js +1 -0
  783. package/dist/web/_app/immutable/nodes/14.B31HrQ26.js +1 -0
  784. package/dist/web/_app/immutable/nodes/15.DxgnZ61h.js +1 -0
  785. package/dist/web/_app/immutable/nodes/16.DAP8cwVb.js +1 -0
  786. package/dist/web/_app/immutable/nodes/17.CmwXtCDY.js +77 -0
  787. package/dist/web/_app/immutable/nodes/2.BuGXEIlN.js +122 -0
  788. package/dist/web/_app/immutable/nodes/3.CmhZJWHE.js +6 -0
  789. package/dist/web/_app/immutable/nodes/4.BRfoPTm-.js +11 -0
  790. package/dist/web/_app/immutable/nodes/5.C7gPuZM_.js +1 -0
  791. package/dist/web/_app/immutable/nodes/6.qKODVbya.js +6 -0
  792. package/dist/web/_app/immutable/nodes/7.BZO37zAJ.js +1 -0
  793. package/dist/web/_app/immutable/nodes/8.xF_Dd1BO.js +1 -0
  794. package/dist/web/_app/immutable/nodes/9.Bow4UyKV.js +1 -0
  795. package/dist/web/_app/version.json +1 -1
  796. package/dist/web/favicon-16.png +0 -0
  797. package/dist/web/favicon-180.png +0 -0
  798. package/dist/web/favicon-32.png +0 -0
  799. package/dist/web/favicon-48.png +0 -0
  800. package/dist/web/favicon-512.png +0 -0
  801. package/dist/web/favicon.ico +0 -0
  802. package/dist/web/favicon.svg +8 -0
  803. package/dist/web/index.html +56 -21
  804. package/dist/web/studiograph-logomark.svg +29 -0
  805. package/package.json +35 -14
  806. package/dist/agent/skills/enrich-entities.md +0 -136
  807. package/dist/agent/skills/sync-configuration.md +0 -119
  808. package/dist/agent/skills/sync-setup.md +0 -82
  809. package/dist/agent/tools/message-tools.d.ts +0 -42
  810. package/dist/agent/tools/message-tools.js +0 -106
  811. package/dist/agent/tools/message-tools.js.map +0 -1
  812. package/dist/cli/commands/app.d.ts +0 -7
  813. package/dist/cli/commands/app.js +0 -268
  814. package/dist/cli/commands/app.js.map +0 -1
  815. package/dist/cli/commands/clear.d.ts +0 -5
  816. package/dist/cli/commands/clear.js +0 -36
  817. package/dist/cli/commands/clear.js.map +0 -1
  818. package/dist/cli/commands/collection.d.ts +0 -10
  819. package/dist/cli/commands/collection.js.map +0 -1
  820. package/dist/cli/commands/join.d.ts +0 -9
  821. package/dist/cli/commands/join.js +0 -255
  822. package/dist/cli/commands/join.js.map +0 -1
  823. package/dist/cli/commands/start.d.ts +0 -7
  824. package/dist/cli/commands/start.js +0 -584
  825. package/dist/cli/commands/start.js.map +0 -1
  826. package/dist/cli/commands/update.d.ts +0 -8
  827. package/dist/cli/commands/update.js +0 -155
  828. package/dist/cli/commands/update.js.map +0 -1
  829. package/dist/cli/commands/user.d.ts +0 -7
  830. package/dist/cli/commands/user.js +0 -175
  831. package/dist/cli/commands/user.js.map +0 -1
  832. package/dist/cli/scaffolding.d.ts +0 -12
  833. package/dist/cli/scaffolding.js +0 -397
  834. package/dist/cli/scaffolding.js.map +0 -1
  835. package/dist/integrations/registry.d.ts +0 -8
  836. package/dist/integrations/registry.js +0 -7
  837. package/dist/integrations/registry.js.map +0 -1
  838. package/dist/integrations/types.d.ts +0 -43
  839. package/dist/integrations/types.js +0 -5
  840. package/dist/integrations/types.js.map +0 -1
  841. package/dist/lib/lib/utils.d.ts +0 -2
  842. package/dist/lib/lib/utils.js +0 -6
  843. package/dist/lib/lib/utils.js.map +0 -1
  844. package/dist/server/chrome/chrome.css +0 -691
  845. package/dist/server/chrome/chrome.js +0 -374
  846. package/dist/server/commit-scheduler.d.ts +0 -39
  847. package/dist/server/commit-scheduler.js +0 -113
  848. package/dist/server/commit-scheduler.js.map +0 -1
  849. package/dist/server/plugin-loader.d.ts +0 -53
  850. package/dist/server/plugin-loader.js +0 -155
  851. package/dist/server/plugin-loader.js.map +0 -1
  852. package/dist/server/routes/meeting-ingest-api.d.ts +0 -14
  853. package/dist/server/routes/meeting-ingest-api.js.map +0 -1
  854. package/dist/server/routes/messages-api.d.ts +0 -15
  855. package/dist/server/routes/messages-api.js +0 -385
  856. package/dist/server/routes/messages-api.js.map +0 -1
  857. package/dist/services/batch-processor.d.ts +0 -49
  858. package/dist/services/batch-processor.js +0 -166
  859. package/dist/services/batch-processor.js.map +0 -1
  860. package/dist/services/briefing.d.ts +0 -26
  861. package/dist/services/briefing.js +0 -230
  862. package/dist/services/briefing.js.map +0 -1
  863. package/dist/services/heartbeat.d.ts +0 -28
  864. package/dist/services/heartbeat.js +0 -183
  865. package/dist/services/heartbeat.js.map +0 -1
  866. package/dist/services/image-import-service.d.ts +0 -24
  867. package/dist/services/image-import-service.js +0 -108
  868. package/dist/services/image-import-service.js.map +0 -1
  869. package/dist/services/insights.d.ts +0 -38
  870. package/dist/services/insights.js +0 -269
  871. package/dist/services/insights.js.map +0 -1
  872. package/dist/services/meeting-processor.js.map +0 -1
  873. package/dist/services/message-service.d.ts +0 -68
  874. package/dist/services/message-service.js +0 -220
  875. package/dist/services/message-service.js.map +0 -1
  876. package/dist/utils/workspace-config.d.ts +0 -8
  877. package/dist/utils/workspace-config.js +0 -22
  878. package/dist/utils/workspace-config.js.map +0 -1
  879. package/dist/web/_app/immutable/assets/0.uPSqtsC5.css +0 -1
  880. package/dist/web/_app/immutable/assets/11.2jzI3cZY.css +0 -1
  881. package/dist/web/_app/immutable/assets/12.CT4xL4K3.css +0 -1
  882. package/dist/web/_app/immutable/assets/13.BUrHkYry.css +0 -1
  883. package/dist/web/_app/immutable/assets/2.DpQWr6q6.css +0 -1
  884. package/dist/web/_app/immutable/assets/3.BxdqT7zi.css +0 -1
  885. package/dist/web/_app/immutable/assets/4.DdNqjd0T.css +0 -1
  886. package/dist/web/_app/immutable/assets/5.DFeGxLYf.css +0 -1
  887. package/dist/web/_app/immutable/assets/6.DXZr_rI_.css +0 -1
  888. package/dist/web/_app/immutable/assets/GraphView.D9ePpZez.css +0 -1
  889. package/dist/web/_app/immutable/assets/Toaster.rN8r_Hzv.css +0 -1
  890. package/dist/web/_app/immutable/assets/ViewToolbar.BWE03S64.css +0 -1
  891. package/dist/web/_app/immutable/assets/WorkspaceView.CgGDi_Zb.css +0 -1
  892. package/dist/web/_app/immutable/assets/wikilinks.CzMBkUMB.css +0 -1
  893. package/dist/web/_app/immutable/chunks/-jG4Obyh.js +0 -4
  894. package/dist/web/_app/immutable/chunks/2nrc8f_O.js +0 -1
  895. package/dist/web/_app/immutable/chunks/7S2LGqoP.js +0 -2
  896. package/dist/web/_app/immutable/chunks/8Q3ZJIYu.js +0 -1
  897. package/dist/web/_app/immutable/chunks/B8ydT5xX.js +0 -2
  898. package/dist/web/_app/immutable/chunks/BB_5th5W.js +0 -3383
  899. package/dist/web/_app/immutable/chunks/BEn6N5c2.js +0 -1
  900. package/dist/web/_app/immutable/chunks/BFNNbCAM.js +0 -1
  901. package/dist/web/_app/immutable/chunks/BFZZEUd5.js +0 -1
  902. package/dist/web/_app/immutable/chunks/BGk8kfOE.js +0 -2
  903. package/dist/web/_app/immutable/chunks/BIBCr278.js +0 -1
  904. package/dist/web/_app/immutable/chunks/BPCkkwqe.js +0 -1
  905. package/dist/web/_app/immutable/chunks/BhlvzGQx.js +0 -1
  906. package/dist/web/_app/immutable/chunks/BlMknQeF.js +0 -1
  907. package/dist/web/_app/immutable/chunks/BpjqrlCg.js +0 -1
  908. package/dist/web/_app/immutable/chunks/BrebtJNG.js +0 -18
  909. package/dist/web/_app/immutable/chunks/ByBZ7JIT.js +0 -1
  910. package/dist/web/_app/immutable/chunks/Bz0ZjVQE.js +0 -2
  911. package/dist/web/_app/immutable/chunks/BzJTZOK2.js +0 -1
  912. package/dist/web/_app/immutable/chunks/C0LVZhvn.js +0 -1
  913. package/dist/web/_app/immutable/chunks/C3uBrOS5.js +0 -1
  914. package/dist/web/_app/immutable/chunks/C8R1sDpW.js +0 -1
  915. package/dist/web/_app/immutable/chunks/CDRHrs0g.js +0 -1
  916. package/dist/web/_app/immutable/chunks/CN5_py1I.js +0 -1
  917. package/dist/web/_app/immutable/chunks/CPne482a.js +0 -1
  918. package/dist/web/_app/immutable/chunks/CSVbGg_b.js +0 -5
  919. package/dist/web/_app/immutable/chunks/CWyU-seV.js +0 -1
  920. package/dist/web/_app/immutable/chunks/CX_61fY8.js +0 -5
  921. package/dist/web/_app/immutable/chunks/CYrVHOHA.js +0 -1
  922. package/dist/web/_app/immutable/chunks/ChsiIm-E.js +0 -1
  923. package/dist/web/_app/immutable/chunks/CojKppbh.js +0 -1
  924. package/dist/web/_app/immutable/chunks/CqkleIqs.js +0 -1
  925. package/dist/web/_app/immutable/chunks/CtYLtD7K.js +0 -1
  926. package/dist/web/_app/immutable/chunks/D-HDy5qS.js +0 -1
  927. package/dist/web/_app/immutable/chunks/D5aIYSkd.js +0 -1
  928. package/dist/web/_app/immutable/chunks/DBBXARVf.js +0 -1
  929. package/dist/web/_app/immutable/chunks/DFYOFE3o.js +0 -1
  930. package/dist/web/_app/immutable/chunks/DKaafS50.js +0 -1
  931. package/dist/web/_app/immutable/chunks/DUMOo1Pn.js +0 -1
  932. package/dist/web/_app/immutable/chunks/DWX9f6AF.js +0 -1
  933. package/dist/web/_app/immutable/chunks/DY8-EONP.js +0 -1
  934. package/dist/web/_app/immutable/chunks/DkoHPElM.js +0 -1
  935. package/dist/web/_app/immutable/chunks/DnL9f0lc.js +0 -7
  936. package/dist/web/_app/immutable/chunks/DsnmJJEf.js +0 -1
  937. package/dist/web/_app/immutable/chunks/DujahU3W.js +0 -1
  938. package/dist/web/_app/immutable/chunks/FBn-ES5k.js +0 -1
  939. package/dist/web/_app/immutable/chunks/HQpwSOb3.js +0 -1
  940. package/dist/web/_app/immutable/chunks/KyjzlWRN.js +0 -2
  941. package/dist/web/_app/immutable/chunks/PPVm8Dsz.js +0 -1
  942. package/dist/web/_app/immutable/chunks/UwYfmrPx.js +0 -2
  943. package/dist/web/_app/immutable/chunks/W25ZVI8L.js +0 -2
  944. package/dist/web/_app/immutable/chunks/WSUKABI_.js +0 -1
  945. package/dist/web/_app/immutable/chunks/XjsuEnNE.js +0 -5
  946. package/dist/web/_app/immutable/chunks/ZD6ARAtb.js +0 -1
  947. package/dist/web/_app/immutable/chunks/pBTf4stg.js +0 -2
  948. package/dist/web/_app/immutable/chunks/rGIarcnp.js +0 -1
  949. package/dist/web/_app/immutable/chunks/vxEtkL8z.js +0 -1
  950. package/dist/web/_app/immutable/chunks/wDIGUaoU.js +0 -3
  951. package/dist/web/_app/immutable/entry/app.ZHiTM8WS.js +0 -2
  952. package/dist/web/_app/immutable/entry/start.C9-FfAVc.js +0 -1
  953. package/dist/web/_app/immutable/nodes/0.D92Orz8k.js +0 -2
  954. package/dist/web/_app/immutable/nodes/1.BMdyglM_.js +0 -1
  955. package/dist/web/_app/immutable/nodes/10.BilVDHSL.js +0 -1
  956. package/dist/web/_app/immutable/nodes/11.CS9E0Hic.js +0 -1
  957. package/dist/web/_app/immutable/nodes/12.D3BOxYzG.js +0 -1
  958. package/dist/web/_app/immutable/nodes/13.CClMKYtN.js +0 -1
  959. package/dist/web/_app/immutable/nodes/2.C8KKElPL.js +0 -267
  960. package/dist/web/_app/immutable/nodes/3.C5n5uiWU.js +0 -5
  961. package/dist/web/_app/immutable/nodes/4.DcGVvgiL.js +0 -11
  962. package/dist/web/_app/immutable/nodes/5.DxIjnFQO.js +0 -4
  963. package/dist/web/_app/immutable/nodes/6.lkh6kKLj.js +0 -1
  964. package/dist/web/_app/immutable/nodes/7.Bg7SbfhA.js +0 -1
  965. package/dist/web/_app/immutable/nodes/8.DcXWCNBE.js +0 -1
  966. package/dist/web/_app/immutable/nodes/9.Cefi8Jfm.js +0 -1
@@ -4,25 +4,120 @@
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, isAbsolute } from 'path';
8
+ import { existsSync, readdirSync, rmSync, statSync, writeFileSync, mkdirSync } 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, RESERVED_ENTITY_TYPES, assertValidTaskDates } from './validation.js';
14
+ import { isInternalField } from './schema-registry.js';
13
15
  import { CSVService } from '../services/csv-service.js';
16
+ import { commitMessage as commitMsg, titleCaseType } from './commit-messages.js';
17
+ import { computeChange, parseFrontmatterCacheFree } from './history-diff.js';
18
+ import { folderPathFromFile, normalizeFolderPath, validateFolderPath, InvalidFolderPathError } from './folder-paths.js';
19
+ import { FOLDER_SIDECAR, readFolderSidecar, readFolderSidecarData, readFolderSidecarId, writeFolderSidecar, writeFolderSidecarData } from './folder-sidecar.js';
14
20
  import { z } from 'zod';
21
+ /** Names skipped during recursive repo walks. */
22
+ const WALK_IGNORE = new Set(['.git', '.studiograph', 'node_modules']);
23
+ /**
24
+ * Compute the canonical revision hash for a body string. Used by the
25
+ * Phase 2 baseRevision precondition. SHA-256 hex (64 chars), matches the
26
+ * format Phase 3's `X-Studiograph-Base-Body-Hash` header expects.
27
+ */
28
+ function sha256OfBody(body) {
29
+ return createHash('sha256').update(body, 'utf-8').digest('hex');
30
+ }
31
+ /**
32
+ * Thrown by `graph.update` when the git commit fails after the disk write
33
+ * has already landed. The .md is updated and observable to subsequent
34
+ * readers via `graph.get`, but no commit exists — the repo is dirty.
35
+ *
36
+ * The caller MUST surface this typed error so an operator can recover
37
+ * (manual `git add && git commit`, or `git checkout -- <path>` to revert
38
+ * disk). v3-of-the-plan claim of "auto-convergence via sessionManager"
39
+ * was false outside the autosave PUT path; Phase 2 explicitly registers
40
+ * server-side dirty files with sessionManager when available, but the
41
+ * caller still gets the typed error so it knows the write was partial.
42
+ */
43
+ export class CommitFailedDirtyError extends Error {
44
+ entityType;
45
+ entityId;
46
+ path;
47
+ code = 'COMMIT_FAILED_DIRTY';
48
+ constructor(entityType, entityId, path, cause) {
49
+ const causeMsg = cause instanceof Error ? cause.message : String(cause);
50
+ super(`Commit failed after disk write for ${entityType}/${entityId} (${path}). ` +
51
+ `Disk is updated but uncommitted (dirty). Recover with \`git add ${path} && git commit\` ` +
52
+ `or \`git checkout -- ${path}\` to revert. Underlying error: ${causeMsg}`);
53
+ this.entityType = entityType;
54
+ this.entityId = entityId;
55
+ this.path = path;
56
+ this.name = 'CommitFailedDirtyError';
57
+ this.cause = cause;
58
+ }
59
+ }
60
+ /**
61
+ * Thrown by `graph.update` when a body write supplies a `baseRevision`
62
+ * that doesn't match the current body's content hash. Caller (autosave,
63
+ * MCP, agent, event processor) refetches and retries against the new
64
+ * revision.
65
+ */
66
+ export class StaleBaseRevisionError extends Error {
67
+ entityType;
68
+ entityId;
69
+ expected;
70
+ actual;
71
+ code = 'STALE_BASE_REVISION';
72
+ constructor(entityType, entityId, expected, actual) {
73
+ super(`Stale base_revision for ${entityType}/${entityId}: caller supplied ${expected.slice(0, 12)}…, ` +
74
+ `current is ${actual.slice(0, 12)}…. Refetch current content and retry.`);
75
+ this.entityType = entityType;
76
+ this.entityId = entityId;
77
+ this.expected = expected;
78
+ this.actual = actual;
79
+ this.name = 'StaleBaseRevisionError';
80
+ }
81
+ }
82
+ /**
83
+ * Thrown by `restoreFile` when the path the recreate would land at is
84
+ * currently occupied by a different entity. Carries the occupant's
85
+ * coordinates so callers (agent tool, REST endpoint) can surface a
86
+ * useful error to the user.
87
+ */
88
+ export class PathConflictError extends Error {
89
+ path;
90
+ occupiedBy;
91
+ code = 'PATH_CONFLICT';
92
+ constructor(path, occupiedBy) {
93
+ super(`Path ${path} is occupied by ${occupiedBy.entityType}/${occupiedBy.entityId}`);
94
+ this.path = path;
95
+ this.occupiedBy = occupiedBy;
96
+ this.name = 'PathConflictError';
97
+ }
98
+ }
15
99
  export class BaseGraphManager {
16
100
  repoPath;
17
101
  repoName;
18
102
  gitService;
19
103
  noGit;
20
104
  markdownService;
105
+ csvService;
21
106
  assetService;
107
+ /** Workspace-global ownership index for immutable-keyed assets. Injected by
108
+ * WorkspaceManager (one per workspace). Undefined in coordinator-less test
109
+ * / CLI graphs — those fall back to the legacy `{type}/{id}` key scheme. */
110
+ assetIndex;
22
111
  gitUser;
23
- csvService;
24
112
  vectorService;
25
113
  schemaRegistry;
114
+ workspaceRoot;
115
+ /** Cache for history-panel change summaries, keyed by `${relPath}@${hash}`.
116
+ * A commit's diff against its parent is immutable, so this never needs
117
+ * invalidation — entries only accumulate as history is viewed. */
118
+ historyChangeCache = new Map();
119
+ persistenceCallbacks;
120
+ bodyWriteCoordinator;
26
121
  constructor(config) {
27
122
  this.repoPath = config.repoPath;
28
123
  this.repoName = config.repoName;
@@ -30,6 +125,7 @@ export class BaseGraphManager {
30
125
  this.noGit = config.noGit ?? false;
31
126
  this.vectorService = config.vectorService;
32
127
  this.schemaRegistry = config.schemaRegistry;
128
+ this.workspaceRoot = config.workspaceRoot;
33
129
  // Initialize services
34
130
  if (!this.noGit) {
35
131
  this.gitService = new GitService(this.repoPath);
@@ -43,10 +139,185 @@ export class BaseGraphManager {
43
139
  // Ensure repo is initialized
44
140
  this.ensureRepo();
45
141
  }
46
- /** Expose git service for deferred commit operations (e.g. CommitScheduler). */
142
+ /**
143
+ * Friendly display label for an entity type — custom-type label when the
144
+ * workspace defines one, otherwise the title-cased key ("deal" → "Deal").
145
+ * Used to build human-readable commit messages.
146
+ */
147
+ typeLabel(type) {
148
+ return this.schemaRegistry?.getCustomTypeLabel(type) ?? titleCaseType(type);
149
+ }
150
+ /** Build an EntityRef for a commit message from a type, id, and optional title. */
151
+ commitRef(type, id, title) {
152
+ return { typeLabel: this.typeLabel(type), title: typeof title === 'string' ? title : undefined, id };
153
+ }
154
+ /**
155
+ * Build the friendly "Edited …" message for a deferred (batched) commit,
156
+ * given the "type/id" pairs accumulated by the SessionManager. Each title is
157
+ * resolved from the in-memory index at flush time (cheap lookup); an entity
158
+ * deleted before the flush falls back to its slug id.
159
+ */
160
+ buildEditedBatchMessage(entities) {
161
+ const refs = entities.map(({ type, id }) => {
162
+ let title;
163
+ try {
164
+ title = this.get(type, id).data?.name;
165
+ }
166
+ catch { /* gone → slug */ }
167
+ return this.commitRef(type, id, title);
168
+ });
169
+ return commitMsg.editedBatch(refs);
170
+ }
171
+ /**
172
+ * Change summary for one commit in a file's history — which frontmatter
173
+ * fields moved and whether the body changed — by diffing the file at `hash`
174
+ * against its true git parent (`hash^`). Used by the entity History panel.
175
+ *
176
+ * `relPath` is repo-relative. Null parent (root commit, or file absent at the
177
+ * parent) → `created`. Result is immutable per (path, hash), so it's cached.
178
+ * Returns null only when the file isn't present at `hash`.
179
+ */
180
+ getCommitChange(relPath, hash) {
181
+ if (!this.gitService)
182
+ return null;
183
+ const cacheKey = `${relPath}@${hash}`;
184
+ const cached = this.historyChangeCache.get(cacheKey);
185
+ if (cached)
186
+ return cached;
187
+ const current = this.gitService.getFileAtCommit(relPath, hash);
188
+ if (current === null)
189
+ return null;
190
+ // getFileAtCommit returns null when `hash^` has no commit (root) OR the
191
+ // file didn't exist there — both mean "created at this commit".
192
+ const parent = this.gitService.getFileAtCommit(relPath, `${hash}^`);
193
+ const change = computeChange(current, parent);
194
+ this.historyChangeCache.set(cacheKey, change);
195
+ return change;
196
+ }
197
+ /** Expose git service for deferred commit operations (SessionManager). */
47
198
  getGitService() {
48
199
  return this.gitService;
49
200
  }
201
+ /** Absolute repo path on disk. Used by callers that need to convert
202
+ * an entity's absolute path to a repo-relative one (e.g. for `git log`). */
203
+ getRepoPath() {
204
+ return this.repoPath;
205
+ }
206
+ /**
207
+ * Find an entity at a specific commit (existing or deleted) and
208
+ * return its repo-relative path + content. Public surface for the
209
+ * history tools — wraps the private `findEntityInCommit` walker.
210
+ * Returns null when no .md file at that commit matches the
211
+ * (entityType, entityId) pair.
212
+ */
213
+ getEntityAtCommit(entityType, entityId, commitHash) {
214
+ return this.findEntityInCommit(entityType, entityId, commitHash);
215
+ }
216
+ /**
217
+ * `git pull` this repo, then reconcile any live editor Y.Doc whose .md the
218
+ * pull rewrote. Mirrors the restore-reconcile fix (see `restoreFile`): a
219
+ * pull changes files on disk WITHOUT flowing through the coordinator's
220
+ * body-write path, so an open editor still holds the pre-pull content and
221
+ * would sync it back over the pull on its next autosave (silent loss of the
222
+ * pulled-in changes). Routing each changed entity through the coordinator
223
+ * acquires the per-doc lock and smart-merges disk into the live Y.Doc.
224
+ *
225
+ * The wrinkle vs. restore: a pull is repo-wide, so we diff HEAD before/after
226
+ * to find the changed .md files and reconcile EACH one's docName rather than
227
+ * a single known entity. To avoid parsing every changed file on a large pull
228
+ * when nobody is editing, the loop is gated to the entities that actually
229
+ * have a live Y.Doc open (`coordinator.activeDocNames()`).
230
+ *
231
+ * Throws if the pull itself fails (merge conflict, no upstream, etc.) — the
232
+ * caller (POST /api/pull) catches per-repo. Reconcile only runs after a
233
+ * successful pull, so a conflicted/aborted pull never reconciles.
234
+ *
235
+ * Returns the docNames that were reconciled (empty when nothing was open,
236
+ * the repo has no git service, or the pull was a no-op fast-forward to the
237
+ * same HEAD).
238
+ */
239
+ async pullAndReconcile() {
240
+ if (!this.gitService)
241
+ return { reconciled: [] };
242
+ const before = this.gitService.getHeadCommit();
243
+ this.gitService.pull();
244
+ const after = this.gitService.getHeadCommit();
245
+ // No new commits (already up to date) or no coordinator wired → the pull
246
+ // is the only effect; freshly-opened editors seed from the new disk state.
247
+ const coordinator = this.bodyWriteCoordinator;
248
+ if (!coordinator || !before || !after || before === after) {
249
+ return { reconciled: [] };
250
+ }
251
+ // The pull changed files on disk behind the in-memory file index (entity →
252
+ // path). Drop it before reconciling: reconcileFromDisk reads through
253
+ // `graph.get`, which resolves the entity's path via that index, so a stale
254
+ // path after a remote MOVE/RENAME/DELETE would make `graph.get` read a
255
+ // now-gone file, return '', and wipe the live Y.Doc. Mirrors the
256
+ // invalidateIndex() every local mutation already does.
257
+ this.invalidateIndex();
258
+ // Gate to the entities with a live Y.Doc open in THIS repo. When nobody is
259
+ // editing here, skip the per-file parse loop entirely — the pull already
260
+ // updated disk and later openers seed from it.
261
+ const prefix = `${this.repoName}/`;
262
+ const activeDocs = new Set(coordinator.activeDocNames().filter(d => d.startsWith(prefix)));
263
+ if (activeDocs.size === 0)
264
+ return { reconciled: [] };
265
+ // entityId (the .md basename) of each open doc — lets us skip parsing a
266
+ // changed file whose id isn't open before identifying its type.
267
+ const activeIds = new Set([...activeDocs].map(d => d.split('/').slice(2).join('/')));
268
+ const reconciled = [];
269
+ for (const rel of this.gitService.diffNameOnly(before, after)) {
270
+ if (!rel.endsWith('.md'))
271
+ continue;
272
+ const stem = rel.split('/').pop().replace(/\.md$/, '');
273
+ if (!activeIds.has(stem))
274
+ continue; // no open editor by this id
275
+ const absPath = join(this.repoPath, rel);
276
+ if (!existsSync(absPath))
277
+ continue; // deleted by the pull — no live body to reconcile to
278
+ const ent = this.identifyEntityAtPath(absPath);
279
+ if (!ent)
280
+ continue;
281
+ const docName = `${this.repoName}/${ent.entityType}/${ent.entityId}`;
282
+ if (!activeDocs.has(docName))
283
+ continue; // same id, different type — not the open doc
284
+ await coordinator.reconcileFromDisk({
285
+ repo: this.repoName,
286
+ entityType: ent.entityType,
287
+ entityId: ent.entityId,
288
+ entityPath: absPath,
289
+ });
290
+ reconciled.push(docName);
291
+ }
292
+ return { reconciled };
293
+ }
294
+ /** Workspace root passed in via GraphConfig. Used by the asset upload
295
+ * service to anchor PDF text sidecars at workspace level (so all repos
296
+ * share the same `assets-text/` tree, mirroring the shared
297
+ * `.studiograph/assets/` directory the local backend uses). */
298
+ getWorkspaceRoot() {
299
+ return this.workspaceRoot;
300
+ }
301
+ /**
302
+ * Install structural-mutation callbacks. Called once after construction by
303
+ * the server bootstrap (or test setup) so the Yjs persistence layer can
304
+ * react to rename/delete/changeType without route-layer code calling
305
+ * persistence APIs directly.
306
+ */
307
+ setPersistenceCallbacks(callbacks) {
308
+ this.persistenceCallbacks = callbacks;
309
+ }
310
+ /**
311
+ * Install the body-write coordinator. graph.update delegates the body-write
312
+ * critical section (lock, base-hash, atomic disk write, commit, broadcast,
313
+ * mdRevisionHash bookkeeping, assertWritable) to the coordinator. When
314
+ * unset (test fixtures without a Y.Doc layer), body writes fall back to a
315
+ * direct disk-write + commit path with no lock/broadcast — preserves the
316
+ * legacy "no callbacks" behavior some test setups rely on.
317
+ */
318
+ setBodyWriteCoordinator(coordinator) {
319
+ this.bodyWriteCoordinator = coordinator;
320
+ }
50
321
  /**
51
322
  * Ensure repository exists
52
323
  */
@@ -57,11 +328,140 @@ export class BaseGraphManager {
57
328
  // GitService automatically initializes in constructor
58
329
  }
59
330
  /**
60
- * Get entity file path: {repo}/{plural-type}/{entityId}.md
331
+ * Compute the on-disk path for a new entity.
332
+ *
333
+ * - When `folderPath` is provided, the entry lives at {repo}/{folderPath}/{id}.md
334
+ * regardless of type. This is the nested-folders layout.
335
+ * - Without `folderPath` (or empty string), the entry lands at the repo
336
+ * root: {repo}/{id}.md. The historical {repo}/{plural-type}/{id}.md
337
+ * fallback was confusing — entries hidden under a typed directory the
338
+ * list view never showed at repo root, contradicting the agent tool
339
+ * description that says "Omit to land at repo root."
340
+ *
341
+ * Note: this is for *new* paths only. Existing entries living under the
342
+ * legacy {plural-type}/ directories continue to be readable via
343
+ * findEntityFile() — only the create path moved.
344
+ */
345
+ getEntityPath(entityType, entityId, folderPath) {
346
+ const normalized = folderPath ? normalizeFolderPath(folderPath) : '';
347
+ if (normalized) {
348
+ return join(this.repoPath, normalized, `${entityId}.md`);
349
+ }
350
+ return join(this.repoPath, `${entityId}.md`);
351
+ }
352
+ // ────────────────────────────────────────────────────────────────────────────
353
+ // Recursive file walk + (type, id) → path index
354
+ //
355
+ // With nested folders, an entry can live at any depth under the repo root.
356
+ // We can't compute its path from (type, id) alone — we have to find it.
357
+ // The index caches that lookup; mutations invalidate it via invalidateIndex().
358
+ // ────────────────────────────────────────────────────────────────────────────
359
+ fileIndex;
360
+ /**
361
+ * Cache of the parsed `list()` view, keyed by `entityType ?? '*'`. Built
362
+ * lazily on first read; invalidated alongside `fileIndex` on every
363
+ * mutation. Without this, autosuggest walks the entire markdown tree
364
+ * and parses YAML frontmatter for every entry on every keystroke (5–7s
365
+ * on workspaces with a few thousand entries).
366
+ *
367
+ * Staleness contract: identical to `fileIndex` — single-process trust
368
+ * model. Git pulls and out-of-process file edits would equally miss
369
+ * the existing `fileIndex` cache; no regression there.
370
+ */
371
+ listCache;
372
+ /** Drop the cached file index so the next read rebuilds it. */
373
+ invalidateIndex() {
374
+ this.fileIndex = undefined;
375
+ this.listCache = undefined;
376
+ }
377
+ /**
378
+ * Walk every .md file under this repo, calling `visit` with the absolute
379
+ * path. Skips hidden directories (.git, .studiograph, etc.) and files that
380
+ * start with ".".
381
+ */
382
+ walkMarkdownFiles(visit) {
383
+ if (!existsSync(this.repoPath))
384
+ return;
385
+ const stack = [this.repoPath];
386
+ while (stack.length > 0) {
387
+ const dir = stack.pop();
388
+ let entries;
389
+ try {
390
+ entries = readdirSync(dir);
391
+ }
392
+ catch {
393
+ continue;
394
+ }
395
+ for (const entry of entries) {
396
+ if (entry.startsWith('.'))
397
+ continue;
398
+ if (WALK_IGNORE.has(entry))
399
+ continue;
400
+ const full = join(dir, entry);
401
+ let st;
402
+ try {
403
+ st = statSync(full);
404
+ }
405
+ catch {
406
+ continue;
407
+ }
408
+ if (st.isDirectory()) {
409
+ stack.push(full);
410
+ }
411
+ else if (st.isFile() && entry.endsWith('.md')) {
412
+ visit(full);
413
+ }
414
+ }
415
+ }
416
+ }
417
+ /**
418
+ * Build (or fetch the cached) "type/id" → absolute path index.
419
+ * Type comes from frontmatter `entity_type`; if missing, falls back to the
420
+ * top-level plural-type directory name (legacy layout). Files without either
421
+ * (e.g. README.md) are skipped — they aren't entities.
422
+ */
423
+ getFileIndex() {
424
+ if (this.fileIndex)
425
+ return this.fileIndex;
426
+ const index = new Map();
427
+ this.walkMarkdownFiles((filePath) => {
428
+ try {
429
+ const doc = this.markdownService.parseFile(filePath);
430
+ const fmType = doc.frontmatter.entity_type;
431
+ const inferred = this.inferLegacyType(filePath);
432
+ const type = fmType || inferred;
433
+ if (!type)
434
+ return; // README, CONTRIBUTING, etc.
435
+ const id = filePath.split('/').pop().replace(/\.md$/, '');
436
+ index.set(`${type}/${id}`, filePath);
437
+ }
438
+ catch { /* skip unparsable */ }
439
+ });
440
+ this.fileIndex = index;
441
+ return index;
442
+ }
443
+ /**
444
+ * Infer entity type from a file's location for legacy layouts where the
445
+ * file sits directly inside a top-level plural-type directory
446
+ * ({repo}/meetings/foo.md). Returns undefined for any other layout.
447
+ */
448
+ inferLegacyType(absPath) {
449
+ const rel = relative(this.repoPath, absPath);
450
+ const parts = rel.split('/');
451
+ if (parts.length !== 2)
452
+ return undefined; // not at depth 1
453
+ return PLURAL_TO_SINGULAR[parts[0]];
454
+ }
455
+ /**
456
+ * Resolve an entity's absolute path by (type, id). Returns undefined if not
457
+ * found. Uses the file index — the index keys by `${entityType}/${entityId}`
458
+ * which is type-aware, unlike the on-disk filename. (We previously had a
459
+ * legacy {plural-type}/{id}.md fast path here; once the create path moved
460
+ * to flat repo-root layout, that fast path could return a different
461
+ * type's file at the same id.)
61
462
  */
62
- getEntityPath(entityType, entityId) {
63
- const pluralDir = SINGULAR_TO_PLURAL[entityType] || `${entityType}s`;
64
- return join(this.repoPath, pluralDir, `${entityId}.md`);
463
+ findEntityFile(entityType, entityId) {
464
+ return this.getFileIndex().get(`${entityType}/${entityId}`);
65
465
  }
66
466
  /**
67
467
  * Validate entity data against schema
@@ -80,12 +480,19 @@ export class BaseGraphManager {
80
480
  try {
81
481
  // Add entity_type and preprocess YAML parsing artefacts:
82
482
  // - null → undefined (Zod .optional() doesn't accept null)
483
+ // - empty string on a date field → undefined (the date vocabulary's
484
+ // `date` / `start_date` are `.date()`-typed and reject ''; old create
485
+ // code seeded '' for absent optional strings, so existing entities
486
+ // carry empty date fields. Coercing to undefined keeps their next
487
+ // update from failing validation and self-heals the stored frontmatter.)
83
488
  // - Date objects → ISO date string (unquoted YAML dates parse as Date)
84
489
  // - bare string for array field → wrap in array (single-value YAML list)
85
490
  const raw = { ...data, entity_type: entityType };
86
491
  const dataWithType = Object.fromEntries(Object.entries(raw).map(([k, v]) => {
87
492
  if (v === null)
88
493
  return [k, undefined];
494
+ if ((k === 'date' || k === 'start_date') && v === '')
495
+ return [k, undefined];
89
496
  if (typeof v === 'object' && v !== null && Object.prototype.toString.call(v) === '[object Date]')
90
497
  return [k, v.toISOString().split('T')[0]];
91
498
  // Coerce scalar string to array for known array-typed fields
@@ -107,11 +514,19 @@ export class BaseGraphManager {
107
514
  return [k, v.toLowerCase()];
108
515
  return [k, v];
109
516
  }));
110
- return schema.parse(dataWithType);
517
+ const result = schema.parse(dataWithType);
518
+ // Cross-field task rule (start <= due). Skipped in lenient mode, which
519
+ // exists to keep already-stored entities discoverable — we don't want a
520
+ // historical bad-ordering to make an entity unreadable. See
521
+ // assertValidTaskDates for why this isn't a Zod refinement.
522
+ if (!lenient && entityType === 'task') {
523
+ assertValidTaskDates(result);
524
+ }
525
+ return result;
111
526
  }
112
527
  catch (error) {
113
528
  if (error instanceof z.ZodError) {
114
- const messages = error.errors.map(e => `${e.path.join('.')}: ${e.message}`);
529
+ const messages = error.issues.map(e => `${e.path.join('.')}: ${e.message}`);
115
530
  throw new Error(`Validation failed for ${entityType}:\n${messages.join('\n')}`);
116
531
  }
117
532
  throw error;
@@ -121,12 +536,18 @@ export class BaseGraphManager {
121
536
  * Create a new entity
122
537
  */
123
538
  async create(options) {
124
- const { entityType, entityId, data, content = '', commitMessage, skipCommit, user } = options;
539
+ const { entityType, entityId, data, content = '', commitMessage, skipCommit, folderPath, user, skipVectorIndex } = options;
540
+ const normalizedFolder = folderPath ? validateFolderPath(folderPath) : '';
125
541
  // Prepopulate date from created_at if not provided
126
542
  if (!data.date && data.created_at) {
127
543
  data.date = String(data.created_at).split('T')[0];
128
544
  }
129
- // Add missing schema fields with empty defaults so the frontmatter is complete
545
+ // Add missing schema fields with empty defaults so the frontmatter is complete.
546
+ // Only string and array fields get populated — empty string isn't a valid
547
+ // member of number/boolean/enum/url types, and seeding them with '' caused
548
+ // optional numeric fields like `deal_value` to fail validation as
549
+ // "Expected number, received string". Anything that can't legitimately
550
+ // hold '' is left absent so the optional check passes.
130
551
  const schema = ENTITY_SCHEMAS[entityType];
131
552
  if (schema) {
132
553
  const shape = schema.shape;
@@ -134,33 +555,56 @@ export class BaseGraphManager {
134
555
  for (const key of Object.keys(shape)) {
135
556
  if (BASE_KEYS.has(key))
136
557
  continue;
558
+ // System-managed fields (source_file, indexable) are never seeded with
559
+ // an empty default — a directly-created entry has NO such field rather
560
+ // than a junk `''`. They're set only by the import/convert path.
561
+ if (isInternalField(shape[key]))
562
+ continue;
137
563
  if (!(key in data)) {
138
564
  const field = shape[key];
139
565
  // Unwrap ZodOptional/ZodDefault to get the inner type
140
566
  const inner = field instanceof z.ZodOptional ? field._def.innerType
141
567
  : 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
568
+ if (inner instanceof z.ZodArray) {
569
+ data[key] = [];
144
570
  continue;
145
571
  }
146
- const isArray = inner instanceof z.ZodArray;
147
- data[key] = isArray ? [] : '';
572
+ if (inner instanceof z.ZodString) {
573
+ // Skip format-constrained strings empty string fails url/date/
574
+ // datetime validation (e.g. `start_date` is now `.date()`-typed).
575
+ const FORMATS = new Set(['url', 'date', 'datetime']);
576
+ if (inner._def.checks?.some((c) => FORMATS.has(c.def?.format)))
577
+ continue;
578
+ data[key] = '';
579
+ continue;
580
+ }
581
+ // Numbers, booleans, enums, literals, unions: leave absent.
148
582
  }
149
583
  }
150
584
  }
151
585
  // Validate data
152
586
  const validatedData = this.validateEntity(entityType, data);
153
- // Check if entity already exists
154
- const entityPath = this.getEntityPath(entityType, entityId);
155
- if (existsSync(entityPath)) {
587
+ // Reject if (type, id) is already taken anywhere in the repo — IDs are
588
+ // unique within (repo, type) regardless of folder.
589
+ const existingPath = this.findEntityFile(entityType, entityId);
590
+ if (existingPath) {
156
591
  throw new Error(`Entity already exists: ${entityType}/${entityId}`);
157
592
  }
593
+ // Compute target path. With folderPath set, entries land at
594
+ // {repo}/{folderPath}/{id}.md (parents auto-created); without, fall back
595
+ // to legacy {plural-type}/{id}.md.
596
+ const entityPath = this.getEntityPath(entityType, entityId, normalizedFolder);
597
+ mkdirSync(dirname(entityPath), { recursive: true });
158
598
  // Write markdown file
159
599
  this.markdownService.writeFile(entityPath, {
160
600
  frontmatter: validatedData,
161
601
  content,
162
602
  wikilinks: this.markdownService.extractWikilinks(content, validatedData),
163
603
  });
604
+ // The folder now has an .md child, so any pre-existing .gitkeep is
605
+ // redundant. Drop it as part of the same commit so the repo stays tidy.
606
+ const removedMarker = this.removeFolderMarker(dirname(entityPath));
607
+ this.invalidateIndex();
164
608
  if (this.gitService && !skipCommit) {
165
609
  const transaction = this.gitService.createTransaction();
166
610
  transaction.add({
@@ -169,21 +613,44 @@ export class BaseGraphManager {
169
613
  entityId,
170
614
  filePath: entityPath,
171
615
  });
172
- const message = commitMessage || `Create ${entityType}: ${entityId}`;
616
+ if (removedMarker) {
617
+ transaction.add({
618
+ operation: 'delete',
619
+ entityType,
620
+ entityId,
621
+ filePath: removedMarker,
622
+ });
623
+ }
624
+ const message = commitMessage || commitMsg.created(this.commitRef(entityType, entityId, data?.name));
173
625
  await transaction.commit(user ?? this.gitUser, message);
174
626
  }
175
627
  // Return created entity
176
628
  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));
629
+ // Update vector index (fire-and-forget, non-fatal). Failures are
630
+ // tracked on VectorService for /api/health visibility the audit
631
+ // (2026-05) flagged that silent .catch(console.warn) made a steady
632
+ // index-failure leak undetectable until search results went stale.
633
+ // `skipVectorIndex` opts out for the asset-upload stub create — see the
634
+ // CreateEntityOptions doc; the finalizing update is the single upsert.
635
+ if (this.vectorService && !skipVectorIndex) {
636
+ const v = this.vectorService;
637
+ v.beginOp();
638
+ v.upsert(this.repoName, created)
639
+ .then(() => v.recordSuccess())
640
+ .catch(err => {
641
+ v.recordFailure('upsert', `${entityType}/${entityId}`, err);
642
+ console.warn(`[vector] index failed for ${entityType}/${entityId}:`, err.message);
643
+ });
644
+ }
179
645
  return created;
180
646
  }
181
647
  /**
182
- * Get an entity by type and ID
648
+ * Get an entity by type and ID. Searches nested folders if the entry isn't
649
+ * at the legacy {plural-type}/{id}.md path.
183
650
  */
184
651
  get(entityType, entityId) {
185
- const entityPath = this.getEntityPath(entityType, entityId);
186
- if (!existsSync(entityPath)) {
652
+ const entityPath = this.findEntityFile(entityType, entityId);
653
+ if (!entityPath) {
187
654
  throw new Error(`Entity not found: ${entityType}/${entityId}`);
188
655
  }
189
656
  const document = this.markdownService.parseFile(entityPath);
@@ -205,115 +672,392 @@ export class BaseGraphManager {
205
672
  return {
206
673
  id: entityId,
207
674
  path: entityPath,
675
+ folder_path: folderPathFromFile(entityPath, this.repoPath),
208
676
  entityType,
209
677
  data: validatedData,
210
678
  document,
211
679
  };
212
680
  }
213
681
  /**
214
- * Get an entity by ID only (type resolved from frontmatter)
682
+ * The live editor body for an entity when a seeded Y.Doc is open, else
683
+ * null. `get` reads disk, which lags the live Y.Doc by the autosave
684
+ * debounce + commit — so a user's freshly-placed image/asset embed exists
685
+ * in the editor before it reaches disk. Callers that must reflect what the
686
+ * user is actually looking at (the get_entity surfaces, the whole-body
687
+ * asset-drop guard) prefer this and fall back to disk on null.
688
+ *
689
+ * Returns null when no coordinator is wired (CLI / test fixtures) — those
690
+ * setups have no live editor layer, so disk IS the authoritative body.
691
+ */
692
+ getLiveBody(entityType, entityId) {
693
+ if (!this.bodyWriteCoordinator?.peekLiveBody)
694
+ return null;
695
+ return this.bodyWriteCoordinator.peekLiveBody({
696
+ repo: this.repoName,
697
+ entityType,
698
+ entityId,
699
+ });
700
+ }
701
+ /**
702
+ * Get an entity by ID only (type resolved from the filesystem).
703
+ *
704
+ * Walks the file index to find any entry with this id, regardless of folder
705
+ * depth or type. Used by the agent's `get_entity` tool when the caller
706
+ * doesn't know the entity's type.
215
707
  */
216
708
  getById(entityId) {
217
- const entityPath = this.getEntityPath('', entityId);
218
- if (!existsSync(entityPath)) {
709
+ if (!existsSync(this.repoPath)) {
219
710
  throw new Error(`Entity not found: ${entityId}`);
220
711
  }
221
- const document = this.markdownService.parseFile(entityPath);
222
- const entityType = document.frontmatter.entity_type ?? this.repoName;
712
+ for (const [key] of this.getFileIndex()) {
713
+ const slash = key.indexOf('/');
714
+ if (slash === -1)
715
+ continue;
716
+ const id = key.slice(slash + 1);
717
+ if (id === entityId) {
718
+ const type = key.slice(0, slash);
719
+ return this.get(type, entityId);
720
+ }
721
+ }
722
+ throw new Error(`Entity not found: ${entityId}`);
723
+ }
724
+ /**
725
+ * Get an entity by its file path — repo-relative (as returned by the
726
+ * search/list tools) or absolute. The most direct resolver: the caller
727
+ * never needs the canonical type, which closes the search→get_entity
728
+ * round-trip for entries whose frontmatter `type` field differs from
729
+ * `entity_type` (e.g. an `event` carrying `type: meeting`). The path is
730
+ * contained to this repo — a `..` escape or out-of-repo path is rejected
731
+ * as not-found rather than read.
732
+ */
733
+ getByPath(filePath) {
734
+ const abs = isAbsolute(filePath) ? filePath : join(this.repoPath, filePath);
735
+ const rel = relative(this.repoPath, abs);
736
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
737
+ throw new Error(`Entity not found: ${filePath}`);
738
+ }
739
+ if (!existsSync(abs)) {
740
+ throw new Error(`Entity not found: ${filePath}`);
741
+ }
742
+ const document = this.markdownService.parseFile(abs);
743
+ const entityType = (document.frontmatter.entity_type
744
+ || this.inferLegacyType(abs)
745
+ || 'note');
746
+ const entityId = abs.split('/').pop().replace(/\.md$/, '');
223
747
  let validatedData;
224
748
  try {
225
749
  validatedData = this.validateEntity(entityType, document.frontmatter);
226
750
  }
227
751
  catch {
228
- validatedData = this.validateEntity(entityType, document.frontmatter, true);
752
+ try {
753
+ validatedData = this.validateEntity(entityType, document.frontmatter, true);
754
+ }
755
+ catch {
756
+ validatedData = document.frontmatter;
757
+ }
229
758
  }
230
759
  return {
231
760
  id: entityId,
232
- path: entityPath,
761
+ path: abs,
762
+ folder_path: folderPathFromFile(abs, this.repoPath),
233
763
  entityType,
234
764
  data: validatedData,
235
765
  document,
236
766
  };
237
767
  }
768
+ /**
769
+ * Merge a caller's `data` onto a base frontmatter, always stamping
770
+ * `updated_at` / `updated_by`, then validate. Even content-only edits
771
+ * (editor autosave, where `data` is undefined) re-stamp the audit fields —
772
+ * otherwise the trail freezes at whatever the last data-change user was,
773
+ * which for scaffolded files is "studiograph".
774
+ *
775
+ * `baseFrontmatter` is the frontmatter to merge onto. The body-write path
776
+ * passes the CURRENT on-disk frontmatter (read inside the per-doc lock) so
777
+ * a concurrent metadata-only edit isn't clobbered; the frontmatter-only
778
+ * path passes `existing.data` (synchronous, no lock window).
779
+ *
780
+ * Validation uses lenient mode + a Date-coercion fallback when no new data
781
+ * is supplied, since the existing entity may have been stored without all
782
+ * required fields, and gray-matter would otherwise serialize Date objects
783
+ * as full ISO timestamps.
784
+ */
785
+ composeUpdatedFrontmatter(entityType, baseFrontmatter, data, user) {
786
+ const updater = (user ?? this.gitUser).id;
787
+ const now = new Date().toISOString();
788
+ const mergedData = data
789
+ ? { ...baseFrontmatter, ...data, entity_type: entityType, updated_at: now, updated_by: updater }
790
+ : { ...baseFrontmatter, updated_at: now, updated_by: updater };
791
+ // Task completion stamp: when a data change moves status to/from `done`,
792
+ // manage `completed_at` here so every surface (app, MCP, agent) gets it
793
+ // for free. Only on a real transition, and never when the caller supplied
794
+ // completed_at explicitly (respect their intent). Skipped for content-only
795
+ // edits (data undefined) so autosave never touches it.
796
+ if (data && entityType === 'task' && !Object.prototype.hasOwnProperty.call(data, 'completed_at')) {
797
+ const priorStatus = baseFrontmatter.status;
798
+ const newStatus = mergedData.status;
799
+ if (newStatus === 'done' && priorStatus !== 'done') {
800
+ mergedData.completed_at = now;
801
+ }
802
+ else if (newStatus !== 'done' && 'completed_at' in mergedData) {
803
+ delete mergedData.completed_at;
804
+ }
805
+ }
806
+ if (data) {
807
+ return this.validateEntity(entityType, mergedData);
808
+ }
809
+ try {
810
+ return this.validateEntity(entityType, mergedData);
811
+ }
812
+ catch {
813
+ try {
814
+ return this.validateEntity(entityType, mergedData, true);
815
+ }
816
+ catch {
817
+ const coerced = Object.fromEntries(Object.entries(mergedData).map(([k, v]) => {
818
+ if (v !== null && typeof v === 'object' && Object.prototype.toString.call(v) === '[object Date]') {
819
+ return [k, v.toISOString().split('T')[0]];
820
+ }
821
+ return [k, v];
822
+ }));
823
+ return coerced;
824
+ }
825
+ }
826
+ }
238
827
  /**
239
828
  * Update an existing entity
240
829
  */
241
830
  async update(options) {
242
- const { entityType, entityId, data, content, commitMessage, skipCommit, user } = options;
831
+ const { entityType, entityId, data, content, commitMessage, skipCommit, user, source, baseRevision } = options;
832
+ // Hard contract: body writes must declare provenance. A missing source
833
+ // used to warn-and-default to 'server-write', but a warning leaves a
834
+ // bypass-shaped footpath for future callers. Every production caller
835
+ // (autosave PUT, event processor, entity-mutations helper) already
836
+ // declares it; throw so a new body-write surface can't silently drift
837
+ // back into ambiguity.
838
+ if (content !== undefined && source === undefined) {
839
+ throw new Error(`update() for ${entityType}/${entityId} passed content without a source. ` +
840
+ `Body writes must declare source: 'autosave' | 'server-write'.`);
841
+ }
243
842
  // Get existing entity
244
843
  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);
844
+ // Update in place write to wherever the entry already lives, not
845
+ // wherever a fresh getEntityPath() computation would put it. Otherwise
846
+ // an entry in a nested folder gets duplicated to the legacy plural-type
847
+ // dir on first update.
848
+ const entityPath = existing.path;
849
+ if (content !== undefined && this.bodyWriteCoordinator) {
850
+ // Body write: delegate the critical section to the coordinator.
851
+ // assertWritable, per-doc lock, base-hash precondition, atomic write,
852
+ // commit, dirty-recovery, and post-commit applyAgentContent all live
853
+ // there now.
854
+ //
855
+ // Frontmatter is merged + validated INSIDE the coordinator's per-doc
856
+ // lock (via prepareFrontmatter) against the CURRENT on-disk
857
+ // frontmatter not a snapshot read here, before the lock. Editor
858
+ // autosave sends `{ content }` only; if we merged against a pre-lock
859
+ // snapshot, a metadata-only edit that commits while this write waits
860
+ // for the lock would be silently clobbered by the stale snapshot.
861
+ const result = await this.bodyWriteCoordinator.writeBody({ repo: this.repoName, entityType, entityId, entityPath }, content, {
862
+ prepareFrontmatter: (currentDiskFrontmatter) => this.composeUpdatedFrontmatter(entityType, currentDiskFrontmatter, data, user),
863
+ baseHash: baseRevision,
864
+ // Guaranteed defined: the guard at the top throws when content is
865
+ // present without a source, and this branch requires content.
866
+ source: source,
867
+ actor: user ?? this.gitUser,
868
+ skipCommit,
869
+ // Default to the friendly "Edited …" subject so server-write body
870
+ // edits (MCP/agent) match create/update/etc. The skipCommit path
871
+ // ignores this and defers to the SessionManager's batch message.
872
+ commitMessage: commitMessage ??
873
+ commitMsg.edited(this.commitRef(entityType, entityId, data?.name ?? existing.data?.name)),
874
+ });
875
+ options.onBroadcast?.(result.broadcast);
262
876
  }
263
877
  else {
264
- try {
265
- validatedData = this.validateEntity(entityType, mergedData);
878
+ // Frontmatter-only write (content === undefined) OR a test fixture
879
+ // running without a coordinator. No concurrent-lock window here: the
880
+ // read-merge-write below is synchronous, so it can't interleave with
881
+ // the coordinator's (also synchronous) critical section.
882
+ const validatedData = this.composeUpdatedFrontmatter(entityType, existing.data, data, user);
883
+ const mergedContent = content !== undefined ? content : existing.document.content;
884
+ // baseRevision is meaningless without a body change; coordinator-less
885
+ // setups exist solely for tests that don't exercise the autosave 412
886
+ // contract.
887
+ if (baseRevision !== undefined && content !== undefined) {
888
+ const currentBody = this.markdownService.parseFile(entityPath).content;
889
+ const currentHash = sha256OfBody(currentBody);
890
+ if (currentHash !== baseRevision) {
891
+ throw new StaleBaseRevisionError(entityType, entityId, baseRevision, currentHash);
892
+ }
266
893
  }
267
- catch {
894
+ this.markdownService.writeFile(entityPath, {
895
+ frontmatter: validatedData,
896
+ content: mergedContent,
897
+ wikilinks: this.markdownService.extractWikilinks(mergedContent, validatedData),
898
+ });
899
+ if (this.gitService && !skipCommit) {
900
+ const transaction = this.gitService.createTransaction();
901
+ transaction.add({
902
+ operation: 'update',
903
+ entityType,
904
+ entityId,
905
+ filePath: entityPath,
906
+ });
907
+ const message = commitMessage || commitMsg.edited(this.commitRef(entityType, entityId, validatedData?.name));
268
908
  try {
269
- validatedData = this.validateEntity(entityType, mergedData, true);
909
+ await transaction.commit(user ?? this.gitUser, message);
270
910
  }
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;
911
+ catch (err) {
912
+ throw new CommitFailedDirtyError(entityType, entityId, entityPath, err);
279
913
  }
280
914
  }
281
915
  }
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
- }
916
+ // The on-disk frontmatter has changed, so the parsed-EntityFile cache
917
+ // that list() serves from is stale until invalidated. Without this, the
918
+ // next list_entities call returns pre-update frontmatter while
919
+ // get_entity (which reads fresh from disk) returns post-update — every
920
+ // other mutation in this file already invalidates after its write.
921
+ this.invalidateIndex();
302
922
  // Return updated entity
303
923
  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));
924
+ // Update vector index (fire-and-forget, non-fatal). Tracked on the
925
+ // service so /api/health surfaces a steady failure stream instead of
926
+ // burying it in stdout warnings.
927
+ if (this.vectorService) {
928
+ const v = this.vectorService;
929
+ v.beginOp();
930
+ v.upsert(this.repoName, updated)
931
+ .then(() => v.recordSuccess())
932
+ .catch(err => {
933
+ v.recordFailure('upsert', `${entityType}/${entityId}`, err);
934
+ console.warn(`[vector] index failed for ${entityType}/${entityId}:`, err.message);
935
+ });
936
+ }
306
937
  return updated;
307
938
  }
308
939
  /**
309
940
  * Delete an entity
310
941
  */
311
- async delete(entityType, entityId, commitMessage, user) {
942
+ /**
943
+ * Delete multiple entities in a single git transaction. Each entity's
944
+ * file is removed from disk; folder markers (.gitkeep) are added where
945
+ * the deletion left a folder empty. Then everything commits as one
946
+ * atomic operation.
947
+ *
948
+ * Compared to looping `delete()` per entity, this is a real performance
949
+ * primitive: one fork of git, one index update, one commit, one ref
950
+ * advance — instead of N. Bulk delete of a few hundred entities goes
951
+ * from minutes to seconds.
952
+ *
953
+ * Errors per entity (e.g. "not found") don't abort the batch — they're
954
+ * collected in `errors` and the rest still commit. Returns both the
955
+ * successfully-deleted set (for vector cleanup + WS broadcast) and the
956
+ * errors so the caller can surface partial failures.
957
+ */
958
+ async bulkDelete(specs, options) {
959
+ const deleted = [];
960
+ const errors = [];
961
+ const transaction = this.gitService?.createTransaction();
962
+ for (const { entityType, entityId } of specs) {
963
+ try {
964
+ const existing = this.get(entityType, entityId);
965
+ rmSync(existing.path, { force: true });
966
+ // Folder marker if the deletion emptied the folder — same lifecycle
967
+ // semantics as single delete().
968
+ const addedMarker = this.ensureFolderMarker(dirname(existing.path));
969
+ if (transaction) {
970
+ transaction.add({ operation: 'delete', entityType, entityId, filePath: existing.path });
971
+ if (addedMarker) {
972
+ transaction.add({ operation: 'create', entityType, entityId, filePath: addedMarker });
973
+ }
974
+ }
975
+ deleted.push({ entityType, entityId, path: existing.path, title: existing.data?.name });
976
+ }
977
+ catch (err) {
978
+ errors.push({
979
+ entityType,
980
+ entityId,
981
+ error: err instanceof Error ? err.message : String(err),
982
+ });
983
+ }
984
+ }
985
+ this.invalidateIndex();
986
+ if (transaction && deleted.length > 0) {
987
+ const message = options?.commitMessage
988
+ ?? commitMsg.deletedBatch(deleted.map(d => this.commitRef(d.entityType, d.entityId, d.title)));
989
+ await transaction.commit(options?.user ?? this.gitUser, message);
990
+ }
991
+ // Asset + vector cleanup for each successfully-deleted entity.
992
+ for (const { entityType, entityId } of deleted) {
993
+ // Immutable-keyed assets owned by this entity (same as single delete()).
994
+ await this.cleanupOwnedAssets(entityType, entityId);
995
+ }
996
+ // Vector index cleanup, fire-and-forget (matches single delete() behavior).
997
+ // Failures recorded on VectorService for /api/health visibility.
998
+ for (const { entityType, entityId } of deleted) {
999
+ if (!this.vectorService)
1000
+ continue;
1001
+ const v = this.vectorService;
1002
+ v.beginOp();
1003
+ v.delete(this.repoName, entityType, entityId)
1004
+ .then(() => v.recordSuccess())
1005
+ .catch(err => {
1006
+ v.recordFailure('delete', `${entityType}/${entityId}`, err);
1007
+ console.warn(`[vector] delete failed for ${entityType}/${entityId}:`, err.message);
1008
+ });
1009
+ }
1010
+ return { deleted, errors };
1011
+ }
1012
+ /**
1013
+ * Commit a set of already-on-disk file changes as a single git
1014
+ * operation. Used by callers (e.g. the import route) that perform many
1015
+ * `skipCommit: true` writes and want one transaction at the end instead
1016
+ * of per-file commits.
1017
+ *
1018
+ * `entries` describes what each file represents so the transaction can
1019
+ * be staged correctly. No-op when nothing is staged or git isn't
1020
+ * available.
1021
+ */
1022
+ async commitPending(entries, options) {
1023
+ if (!this.gitService)
1024
+ return { committed: false };
1025
+ if (entries.length === 0 && !options.extraPaths?.length)
1026
+ return { committed: false };
1027
+ const transaction = this.gitService.createTransaction();
1028
+ for (const e of entries)
1029
+ transaction.add(e);
1030
+ // Auxiliary paths (folder-display sidecars from the import flow) are
1031
+ // staged alongside the entity files so they land in the same commit.
1032
+ // The transaction only uses `filePath` for staging — the other fields
1033
+ // are metadata, so we fill them with sentinel values.
1034
+ if (options.extraPaths?.length) {
1035
+ for (const filePath of options.extraPaths) {
1036
+ transaction.add({
1037
+ operation: 'create',
1038
+ entityType: '_sidecar',
1039
+ entityId: '_sidecar',
1040
+ filePath,
1041
+ });
1042
+ }
1043
+ }
1044
+ await transaction.commit(options.user ?? this.gitUser, options.commitMessage);
1045
+ return { committed: true };
1046
+ }
1047
+ async delete(entityType, entityId, commitMessage, user, opts) {
312
1048
  // Verify entity exists
313
1049
  const existing = this.get(entityType, entityId);
314
1050
  // Delete the flat entity file
315
- const { rmSync } = await import('fs');
316
1051
  rmSync(existing.path, { force: true });
1052
+ // Asset cleanup. Skipped when keepAssets is set — cross-repo MOVE re-points
1053
+ // ownership to the target repo, so the source-side delete must NOT destroy
1054
+ // the blobs that now belong to the moved entity.
1055
+ // Folder lifecycle: deleting an entry never deletes its containing folder.
1056
+ // If this was the last .md in the folder, write a .gitkeep so it stays
1057
+ // visible to listFolders() (and therefore to the sidebar). A user who
1058
+ // wants the folder gone calls deleteFolder() explicitly.
1059
+ const addedMarker = this.ensureFolderMarker(dirname(existing.path));
1060
+ this.invalidateIndex();
317
1061
  if (this.gitService) {
318
1062
  const transaction = this.gitService.createTransaction();
319
1063
  transaction.add({
@@ -322,11 +1066,50 @@ export class BaseGraphManager {
322
1066
  entityId,
323
1067
  filePath: existing.path,
324
1068
  });
325
- const message = commitMessage || `Delete ${entityType}: ${entityId}`;
1069
+ if (addedMarker) {
1070
+ transaction.add({
1071
+ operation: 'create',
1072
+ entityType,
1073
+ entityId,
1074
+ filePath: addedMarker,
1075
+ });
1076
+ }
1077
+ const message = commitMessage || commitMsg.deleted(this.commitRef(entityType, entityId, existing.data?.name));
326
1078
  await transaction.commit(user ?? this.gitUser, message);
327
1079
  }
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));
1080
+ // Asset cleanup AFTER the markdown delete commits — a commit failure
1081
+ // (CommitFailedDirtyError) leaves the entry recoverable, so we must not
1082
+ // have already destroyed its binaries. Matches bulkDelete's ordering.
1083
+ // Skipped when keepAssets is set (cross-repo move re-points ownership).
1084
+ if (!opts?.keepAssets) {
1085
+ // Immutable-keyed assets owned by this entity (any type) via the index.
1086
+ await this.cleanupOwnedAssets(entityType, entityId);
1087
+ }
1088
+ // Remove from vector index (fire-and-forget, non-fatal). Tracked on
1089
+ // the service so a leaking error stream is visible at /api/health.
1090
+ if (this.vectorService) {
1091
+ const v = this.vectorService;
1092
+ v.beginOp();
1093
+ v.delete(this.repoName, entityType, entityId)
1094
+ .then(() => v.recordSuccess())
1095
+ .catch(err => {
1096
+ v.recordFailure('delete', `${entityType}/${entityId}`, err);
1097
+ console.warn(`[vector] delete failed for ${entityType}/${entityId}:`, err.message);
1098
+ });
1099
+ }
1100
+ // Notify Yjs persistence. Awaited (not fire-and-forget) because docName
1101
+ // reuse is a real scenario: delete X then create a new entity at X. If the
1102
+ // delete callback fired async, it could race the new entity's snapshot and
1103
+ // unlink it after creation. Failures are logged but don't throw — orphaned
1104
+ // snapshot files are a leak, not a correctness issue.
1105
+ if (this.persistenceCallbacks?.onDelete) {
1106
+ try {
1107
+ await this.persistenceCallbacks.onDelete(this.repoName, entityType, entityId);
1108
+ }
1109
+ catch (err) {
1110
+ console.warn(`[graph] onDelete callback failed for ${entityType}/${entityId}:`, err);
1111
+ }
1112
+ }
330
1113
  }
331
1114
  /**
332
1115
  * Rename an entity (change its ID). Preserves all data, content, and source_ref.
@@ -334,7 +1117,8 @@ export class BaseGraphManager {
334
1117
  */
335
1118
  async rename(entityType, oldId, newId, commitMessage, user) {
336
1119
  const existing = this.get(entityType, oldId);
337
- const newPath = this.getEntityPath(entityType, newId);
1120
+ // Rename keeps the entry in its current folder — only the filename changes.
1121
+ const newPath = join(dirname(existing.path), `${newId}.md`);
338
1122
  if (existsSync(newPath)) {
339
1123
  throw new Error(`Entity ${entityType}/${newId} already exists`);
340
1124
  }
@@ -348,27 +1132,61 @@ export class BaseGraphManager {
348
1132
  // Remove old file
349
1133
  const { rmSync } = await import('fs');
350
1134
  rmSync(existing.path, { force: true });
351
- // Git: stage the rename
1135
+ this.invalidateIndex();
352
1136
  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);
1137
+ const message = commitMessage || commitMsg.renamed(this.typeLabel(entityType), oldId, newId);
1138
+ this.gitService.commitFiles([existing.path, newPath], message, user ?? this.gitUser);
1139
+ }
1140
+ // Re-point asset ownership to the new id. Keys are immutable, so no blob
1141
+ // moves and the embed URLs in the body keep resolving — this just keeps
1142
+ // listByOwner/delete/transfer correct after the rename.
1143
+ this.reassignOwnedAssets(entityType, oldId, { ownerId: newId });
1144
+ // Update vector index — failure tracked on the service so a steady
1145
+ // leak surfaces at /api/health rather than getting swallowed entirely.
1146
+ if (this.vectorService) {
1147
+ const v = this.vectorService;
1148
+ v.beginOp();
1149
+ v.delete(this.repoName, entityType, oldId)
1150
+ .then(() => v.recordSuccess())
1151
+ .catch(err => v.recordFailure('delete', `${entityType}/${oldId}`, err));
1152
+ }
1153
+ // Notify Yjs persistence to rename the snapshot file alongside. Awaited
1154
+ // because rename oldId→newId followed by creating a new entity at oldId
1155
+ // would otherwise let the async rename clobber the new oldId snapshot.
1156
+ if (this.persistenceCallbacks?.onRename) {
1157
+ try {
1158
+ await this.persistenceCallbacks.onRename(this.repoName, entityType, oldId, newId);
1159
+ }
1160
+ catch (err) {
1161
+ console.warn(`[graph] onRename callback failed for ${entityType}/${oldId}→${newId}:`, err);
1162
+ }
359
1163
  }
360
- // Update vector index
361
- this.vectorService?.delete(this.repoName, entityType, oldId).catch(() => { });
362
1164
  return this.get(entityType, newId);
363
1165
  }
364
1166
  /**
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.
1167
+ * Change an entity's type. Updates frontmatter only the file stays in
1168
+ * its current folder. In the type-agnostic folder model, type is a
1169
+ * frontmatter property independent of location. (Legacy behavior moved
1170
+ * the file between {plural-type}/ dirs; that's no longer correct because
1171
+ * those dirs are now ordinary user-visible folders, not type markers.)
1172
+ *
1173
+ * If the user wants the file to also move (e.g. out of a "meetings/"
1174
+ * folder when changed to a note), they invoke move() separately.
367
1175
  */
368
1176
  async changeType(entityId, fromType, toType, commitMessage, user) {
1177
+ // Don't let change-type re-mint a retired type (`asset`, Phase F). Without
1178
+ // this, `toType: 'asset'` would fall back to EntityBaseSchema and write a
1179
+ // base-only stub of exactly the type we removed. (Only the retired-type
1180
+ // reservation is enforced here; general unknown-target validation incl.
1181
+ // custom types stays a route-level concern.)
1182
+ if (RESERVED_ENTITY_TYPES.has(toType)) {
1183
+ throw new Error(`Cannot change_type to '${toType}' — that entity type was retired.`);
1184
+ }
369
1185
  const existing = this.get(fromType, entityId);
370
- const newPath = this.getEntityPath(toType, entityId);
371
- if (existsSync(newPath)) {
1186
+ // (type, id) uniqueness check — if an entry with the new type already
1187
+ // exists with this id, refuse rather than silently colliding in the index.
1188
+ const collision = this.findEntityFile(toType, entityId);
1189
+ if (collision && collision !== existing.path) {
372
1190
  throw new Error(`Entity ${toType}/${entityId} already exists`);
373
1191
  }
374
1192
  // Read current content
@@ -397,6 +1215,10 @@ export class BaseGraphManager {
397
1215
  for (const key of Object.keys(shape)) {
398
1216
  if (BASE_KEYS.has(key))
399
1217
  continue;
1218
+ // Don't seed system-managed fields (source_file, indexable) — same
1219
+ // rule as the create path.
1220
+ if (isInternalField(shape[key]))
1221
+ continue;
400
1222
  if (!(key in doc.frontmatter)) {
401
1223
  const field = shape[key];
402
1224
  const inner = field instanceof z.ZodOptional ? field._def.innerType
@@ -408,144 +1230,461 @@ export class BaseGraphManager {
408
1230
  }
409
1231
  }
410
1232
  }
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
1233
+ // Write back to the same path file stays in its current folder.
1234
+ this.markdownService.writeFile(existing.path, doc);
1235
+ this.invalidateIndex();
418
1236
  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
1237
+ const message = commitMessage || commitMsg.changedType(this.commitRef(fromType, entityId, existing.data?.name), this.typeLabel(fromType), this.typeLabel(toType));
1238
+ this.gitService.commitFiles([existing.path], message, user ?? this.gitUser);
1239
+ }
1240
+ // Re-point asset ownership to the new type (keys immutable; no blob moves).
1241
+ // The `asset` entity type was retired (Phase F), so no entity carries a PDF
1242
+ // text sidecar keyed under assets-text/asset/ — nothing to move here.
1243
+ this.reassignOwnedAssets(fromType, entityId, { ownerType: toType });
1244
+ // Update vector index: remove old entry, re-index at new type. Both ops
1245
+ // tracked on the service so /api/health surfaces a leaking failure
1246
+ // stream previous .catch(() => {}) form was completely silent.
431
1247
  const updated = this.get(toType, entityId);
432
- this.vectorService?.delete(this.repoName, fromType, entityId).catch(() => { });
433
- this.vectorService?.upsert(this.repoName, updated).catch(() => { });
1248
+ if (this.vectorService) {
1249
+ const v = this.vectorService;
1250
+ v.beginOp();
1251
+ v.delete(this.repoName, fromType, entityId)
1252
+ .then(() => v.recordSuccess())
1253
+ .catch(err => v.recordFailure('delete', `${fromType}/${entityId}`, err));
1254
+ v.beginOp();
1255
+ v.upsert(this.repoName, updated)
1256
+ .then(() => v.recordSuccess())
1257
+ .catch(err => v.recordFailure('upsert', `${toType}/${entityId}`, err));
1258
+ }
1259
+ // Notify Yjs persistence to move the snapshot from the old type's path to
1260
+ // the new one (the docName changes, so the snapshot path changes). Awaited
1261
+ // for the same reason as rename — preserves ordering when types are
1262
+ // changed back and forth quickly.
1263
+ if (this.persistenceCallbacks?.onChangeType) {
1264
+ try {
1265
+ await this.persistenceCallbacks.onChangeType(this.repoName, entityId, fromType, toType);
1266
+ }
1267
+ catch (err) {
1268
+ console.warn(`[graph] onChangeType callback failed for ${entityId} (${fromType}→${toType}):`, err);
1269
+ }
1270
+ }
434
1271
  return updated;
435
1272
  }
436
1273
  /**
437
- * List entities of a given type
1274
+ * Move an entry to a different folder within the same repo. The filename
1275
+ * (and therefore the entity id) and type are unchanged. Auto-creates parent
1276
+ * folders. Refuses if the target path already has an entry with this id —
1277
+ * pass `overwrite: true` to replace it, but only when the occupant is
1278
+ * itself a parseable entity (never silently clobber arbitrary files like
1279
+ * README.md or .gitkeep).
438
1280
  */
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);
1281
+ async move(entityType, entityId, targetFolderPath, commitMessage, user, overwrite) {
1282
+ const existing = this.get(entityType, entityId);
1283
+ const normalized = normalizeFolderPath(targetFolderPath);
1284
+ const newPath = join(this.repoPath, normalized, `${entityId}.md`);
1285
+ if (newPath === existing.path)
1286
+ return existing; // no-op
1287
+ if (existsSync(newPath)) {
1288
+ if (!overwrite) {
1289
+ throw new Error(`Path already occupied: ${normalized}/${entityId}.md`);
448
1290
  }
449
- catch {
450
- try {
451
- return this.validateEntity(type, { ...data, entity_type: type }, true);
452
- }
453
- catch {
454
- return data;
455
- }
1291
+ // Safety guard: only overwrite when the destination is itself an
1292
+ // entity .md (parseable frontmatter or legacy plural-type dir). Refuse
1293
+ // to clobber README.md, CHANGELOG.md, .gitkeep, or anything else that
1294
+ // isn't a tracked entry — that would be data loss the caller didn't
1295
+ // ask for.
1296
+ const occupant = this.identifyEntityAtPath(newPath);
1297
+ if (!occupant) {
1298
+ throw new Error(`Refusing to overwrite non-entity file at ${normalized}/${entityId}.md — ` +
1299
+ `destination must be a parseable entity for overwrite to be safe.`);
456
1300
  }
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
- });
1301
+ }
1302
+ mkdirSync(dirname(newPath), { recursive: true });
1303
+ const { renameSync } = await import('fs');
1304
+ // On POSIX, renameSync atomically replaces the destination if it exists.
1305
+ // The overwrite gate above ensures we only reach this point when the
1306
+ // occupant is a tracked entity (or the path is empty).
1307
+ renameSync(existing.path, newPath);
1308
+ // Folder lifecycle: source stays addressable via .gitkeep if it lost its
1309
+ // last .md; destination drops any redundant .gitkeep now that it has one.
1310
+ const filesToCommit = [existing.path, newPath];
1311
+ const addedSourceMarker = this.ensureFolderMarker(dirname(existing.path));
1312
+ if (addedSourceMarker)
1313
+ filesToCommit.push(addedSourceMarker);
1314
+ const removedDestMarker = this.removeFolderMarker(dirname(newPath));
1315
+ if (removedDestMarker)
1316
+ filesToCommit.push(removedDestMarker);
1317
+ this.invalidateIndex();
1318
+ if (this.gitService) {
1319
+ const message = commitMessage || commitMsg.movedTo(this.commitRef(entityType, entityId, existing.data?.name), normalized || '(root)');
1320
+ this.gitService.commitFiles(filesToCommit, message, user ?? this.gitUser);
1321
+ }
1322
+ return this.get(entityType, entityId);
1323
+ }
1324
+ /**
1325
+ * Restore an entity to its content at a specific commit. Single-entity
1326
+ * scope only — multi-entity rollback (e.g. "undo this commit's effects
1327
+ * across all touched files") is a separate, more cautious flow that
1328
+ * isn't built yet.
1329
+ *
1330
+ * Two paths through the method:
1331
+ * - **Overwrite**: the entity exists today at path `P`. Read content
1332
+ * from `git show <hash>:<P>` and write it back to `P`.
1333
+ * - **Recreate**: the entity is gone today. Walk the historical
1334
+ * commit's tree to find the .md file whose basename matches
1335
+ * `entityId` AND whose frontmatter `entity_type` matches
1336
+ * `entityType`. That path is the restore target. The walk only
1337
+ * fires when the entity is truly absent — overwrites stay cheap.
1338
+ *
1339
+ * Throws a `PathConflictError` if the recreate target path is now
1340
+ * occupied by a DIFFERENT entity (different type+id) — recreating
1341
+ * over it would silently overwrite unrelated work. Caller surfaces
1342
+ * the conflict to the user; resolution is "move or delete the
1343
+ * occupant first."
1344
+ */
1345
+ async restoreFile(entityType, entityId, commitHash, user) {
1346
+ if (!this.gitService) {
1347
+ throw new Error('Cannot restore — git service is not available for this repo');
1348
+ }
1349
+ const existingPath = this.findEntityFile(entityType, entityId);
1350
+ let targetPath;
1351
+ let action;
1352
+ let content;
1353
+ if (existingPath) {
1354
+ // Overwrite path. Read content at the historical commit using the
1355
+ // CURRENT path (git tracks files by content + path; if the entity
1356
+ // was renamed or moved across the file's lifetime, --follow would
1357
+ // be needed — out of scope for v1, the user can resolve by
1358
+ // restoring at a commit where the path matched).
1359
+ const relPath = relative(this.repoPath, existingPath);
1360
+ content = this.gitService.getFileAtCommit(relPath, commitHash);
1361
+ if (content === null) {
1362
+ throw new Error(`File ${relPath} did not exist at commit ${commitHash}`);
478
1363
  }
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('.'))
484
- continue;
485
- const fullPath = join(this.repoPath, entry);
486
- try {
487
- if (!statSync(fullPath).isDirectory())
488
- continue;
1364
+ // Guard: if the entry was change_type'd after this commit, the file path
1365
+ // is unchanged but the historical frontmatter carries the OLD
1366
+ // entity_type. Writing it back would silently revert the type, bypassing
1367
+ // change_type's collision checks, asset re-ownership, vector reindex, and
1368
+ // persistence callbacks — and the route would still broadcast an update
1369
+ // for the current type. Refuse rather than corrupt.
1370
+ // Read entity_type straight from the raw frontmatter block (no shared
1371
+ // markdown-parse layer) so this reflects exactly what's on disk at the
1372
+ // historical commit.
1373
+ const fmBlock = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
1374
+ const historicalType = fmBlock?.[1]
1375
+ .match(/^entity_type:\s*["']?([^"'\r\n]+)["']?\s*$/m)?.[1]
1376
+ ?.trim();
1377
+ if (historicalType && historicalType !== entityType) {
1378
+ throw new Error(`Cannot restore: the entry was type '${historicalType}' at that commit but is '${entityType}' now. ` +
1379
+ `Restoring would change its type without the safeguards of change_type. ` +
1380
+ `Convert it explicitly with change_type if that's intended.`);
489
1381
  }
490
- catch {
491
- continue;
1382
+ targetPath = existingPath;
1383
+ action = 'overwrite';
1384
+ }
1385
+ else {
1386
+ // Recreate path. Walk the historical commit's tree to find the
1387
+ // file whose basename matches entityId and frontmatter type
1388
+ // matches entityType. This is the only place we do a full
1389
+ // tree-walk — it only runs when the entity is gone today.
1390
+ const found = this.findEntityInCommit(entityType, entityId, commitHash);
1391
+ if (!found) {
1392
+ throw new Error(`Entity ${entityType}/${entityId} not found at commit ${commitHash}`);
492
1393
  }
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);
1394
+ targetPath = join(this.repoPath, found.path);
1395
+ content = found.content;
1396
+ action = 'recreate';
1397
+ // Path-conflict check: nothing else may occupy targetPath today.
1398
+ if (existsSync(targetPath)) {
1399
+ const occupant = this.identifyEntityAtPath(targetPath);
1400
+ if (!occupant) {
1401
+ // A non-entity file (README, stray .md, .gitkeep) now sits at the
1402
+ // restore path. identifyEntityAtPath returning null previously fell
1403
+ // through and writeFileSync clobbered it — silent data loss. Treat
1404
+ // any unidentifiable occupant as a conflict.
1405
+ throw new PathConflictError(found.path, {
1406
+ entityType: 'file',
1407
+ entityId: relative(this.repoPath, targetPath),
1408
+ });
1409
+ }
1410
+ if (occupant.entityType !== entityType || occupant.entityId !== entityId) {
1411
+ throw new PathConflictError(found.path, occupant);
510
1412
  }
511
1413
  }
512
- catch { /* skip unreadable */ }
513
1414
  }
514
- // Also discover flat .md files at repo root (README, etc.)
515
- for (const entry of readdirSync(this.repoPath)) {
516
- if (entry.startsWith('.'))
1415
+ mkdirSync(dirname(targetPath), { recursive: true });
1416
+ writeFileSync(targetPath, content);
1417
+ // Folder lifecycle: if recreating dropped a file into a directory
1418
+ // that had been emptied (and now carries .gitkeep), drop the marker
1419
+ // so the directory exists via the .md file alone.
1420
+ const filesToCommit = [targetPath];
1421
+ if (action === 'recreate') {
1422
+ const removedDestMarker = this.removeFolderMarker(dirname(targetPath));
1423
+ if (removedDestMarker)
1424
+ filesToCommit.push(removedDestMarker);
1425
+ }
1426
+ this.invalidateIndex();
1427
+ const shortHash = commitHash.length > 8 ? commitHash.slice(0, 8) : commitHash;
1428
+ const restoredName = content ? this.markdownService.parseContent(content).frontmatter?.name : undefined;
1429
+ const message = commitMsg.restored(this.commitRef(entityType, entityId, restoredName), shortHash);
1430
+ this.gitService.commitFiles(filesToCommit, message, user ?? this.gitUser);
1431
+ // Reconcile any live editor Y.Doc against the restored disk content. restoreFile
1432
+ // writes the file directly — it does NOT flow through update()'s coordinator
1433
+ // body-write path — so an editor with a live Y.Doc still holds the pre-restore
1434
+ // content and would sync it back over the restore on the next autosave, making
1435
+ // the restore look like a no-op. Routing through the coordinator acquires the
1436
+ // per-doc lock (serializing the smart-merge against any in-flight autosave) and,
1437
+ // crucially, covers EVERY restore caller — the HTTP route, the agent history
1438
+ // tool, and the MCP tool — not just one. No-ops when no coordinator is wired
1439
+ // (CLI/tests) or no editor doc is currently active (later openers seed from disk).
1440
+ await this.bodyWriteCoordinator?.reconcileFromDisk({
1441
+ repo: this.repoName,
1442
+ entityType,
1443
+ entityId,
1444
+ entityPath: targetPath,
1445
+ });
1446
+ return { action, path: targetPath };
1447
+ }
1448
+ /**
1449
+ * Walk a commit's tree looking for a .md file whose basename matches
1450
+ * `entityId` AND whose frontmatter `entity_type` matches `entityType`.
1451
+ * Returns repo-relative path + content when found, otherwise null.
1452
+ * Used by `restoreFile` for the recreate-from-deletion case.
1453
+ */
1454
+ findEntityInCommit(entityType, entityId, commitHash) {
1455
+ if (!this.gitService)
1456
+ return null;
1457
+ const filenameLower = `${entityId}.md`.toLowerCase();
1458
+ const tree = this.gitService.listFilesAtCommit(commitHash);
1459
+ for (const path of tree) {
1460
+ if (!path.toLowerCase().endsWith(filenameLower))
517
1461
  continue;
518
- if (!entry.endsWith('.md'))
1462
+ // basename match — read content and check frontmatter type.
1463
+ const content = this.gitService.getFileAtCommit(path, commitHash);
1464
+ if (content === null)
519
1465
  continue;
520
- const fullPath = join(this.repoPath, entry);
521
1466
  try {
522
- if (!statSync(fullPath).isFile())
523
- continue;
1467
+ // Cache-free parse: this is historical content, and the shared
1468
+ // gray-matter layer returned a stale entity_type for past-commit
1469
+ // content (see parseFrontmatterCacheFree), which would mis-match here.
1470
+ const fmType = parseFrontmatterCacheFree(content).entity_type;
1471
+ const inferred = this.inferLegacyType(join(this.repoPath, path));
1472
+ const type = fmType || inferred;
1473
+ if (type === entityType)
1474
+ return { path, content };
524
1475
  }
525
1476
  catch {
526
- continue;
1477
+ // unparsable file — skip
527
1478
  }
528
- const id = entry.replace(/\.md$/, '');
529
- if (seenIds.has(id))
1479
+ }
1480
+ return null;
1481
+ }
1482
+ /**
1483
+ * Identify the entity (if any) currently occupying `absPath`. Returns
1484
+ * null when the path holds something that isn't an entity (README, etc).
1485
+ * Mirrors the inference rules used in `getFileIndex`.
1486
+ */
1487
+ identifyEntityAtPath(absPath) {
1488
+ try {
1489
+ const doc = this.markdownService.parseFile(absPath);
1490
+ const fmType = doc.frontmatter.entity_type;
1491
+ const inferred = this.inferLegacyType(absPath);
1492
+ const type = fmType || inferred;
1493
+ if (!type)
1494
+ return null;
1495
+ const id = absPath.split('/').pop().replace(/\.md$/, '');
1496
+ return { entityType: type, entityId: id };
1497
+ }
1498
+ catch {
1499
+ return null;
1500
+ }
1501
+ }
1502
+ /**
1503
+ * Enumerate the distinct entity types present in this repo. Used by
1504
+ * /api/repos to let the sidebar render the Views list without a full
1505
+ * entity fetch per folder.
1506
+ *
1507
+ * Derived from the file index (which reads frontmatter), so it works for
1508
+ * entries in any folder — not just the legacy {plural-type}/ dirs.
1509
+ */
1510
+ listEntityTypes() {
1511
+ if (!existsSync(this.repoPath))
1512
+ return [];
1513
+ const types = new Set();
1514
+ for (const key of this.getFileIndex().keys()) {
1515
+ const slash = key.indexOf('/');
1516
+ if (slash > 0)
1517
+ types.add(key.slice(0, slash));
1518
+ }
1519
+ return [...types].sort();
1520
+ }
1521
+ /**
1522
+ * Folder lifecycle: a directory is treated as a first-class persistent
1523
+ * object. It exists from the moment it's created (createFolder, the destination
1524
+ * of a moved entry, etc.) until it's explicitly removed via a folder-level
1525
+ * operation (deleteFolder, renameFolder, moveFolderToRepo). Entry-level
1526
+ * operations (create, delete, move, transfer) never decide whether a folder
1527
+ * lives or dies.
1528
+ *
1529
+ * `listFolders()` requires either a .md child or a .gitkeep marker for a
1530
+ * directory to surface in the API, so the two helpers below are the single
1531
+ * place that adds and removes the marker:
1532
+ *
1533
+ * - `ensureFolderMarker` — call after an op that may have left `dir`
1534
+ * without .md children. Writes `.gitkeep` and returns its path so the
1535
+ * caller can stage it in the same commit.
1536
+ * - `removeFolderMarker` — call after an op that placed an .md into `dir`.
1537
+ * Drops a now-redundant `.gitkeep` so the repo stays tidy.
1538
+ *
1539
+ * Both no-op for the repo root (always implicit) and for paths that don't
1540
+ * exist. Both return `null` when nothing changed.
1541
+ */
1542
+ ensureFolderMarker(dir) {
1543
+ if (dir === this.repoPath)
1544
+ return null;
1545
+ if (!existsSync(dir))
1546
+ return null;
1547
+ const entries = readdirSync(dir);
1548
+ const hasMd = entries.some(e => e.endsWith('.md') && !e.startsWith('.'));
1549
+ if (hasMd)
1550
+ return null;
1551
+ const keepPath = join(dir, '.gitkeep');
1552
+ if (existsSync(keepPath))
1553
+ return null;
1554
+ writeFileSync(keepPath, '');
1555
+ return keepPath;
1556
+ }
1557
+ removeFolderMarker(dir) {
1558
+ if (dir === this.repoPath)
1559
+ return null;
1560
+ if (!existsSync(dir))
1561
+ return null;
1562
+ const keepPath = join(dir, '.gitkeep');
1563
+ if (!existsSync(keepPath))
1564
+ return null;
1565
+ const entries = readdirSync(dir);
1566
+ const hasMd = entries.some(e => e.endsWith('.md') && !e.startsWith('.'));
1567
+ if (!hasMd)
1568
+ return null; // marker still load-bearing
1569
+ rmSync(keepPath, { force: true });
1570
+ return keepPath;
1571
+ }
1572
+ /**
1573
+ * Walk up from `dir`, removing each directory that became empty after a
1574
+ * folder-level removal (deleteFolder, renameFolder, moveFolderToRepo).
1575
+ * Stops at the repo root. Silently no-ops if a directory is missing,
1576
+ * non-empty, or can't be removed.
1577
+ *
1578
+ * NOT called by entry-level operations (create/delete/move/transfer) —
1579
+ * they preserve folder structure regardless of whether they emptied a
1580
+ * directory. Use `ensureFolderMarker` instead for those paths.
1581
+ */
1582
+ async pruneEmptyAncestors(dir) {
1583
+ const { rmSync } = await import('fs');
1584
+ const repoRoot = this.repoPath.replace(/\/+$/, '');
1585
+ let current = dir;
1586
+ while (current.startsWith(repoRoot + '/') && current !== repoRoot) {
1587
+ if (!existsSync(current)) {
1588
+ current = dirname(current);
530
1589
  continue;
1590
+ }
1591
+ let entries;
531
1592
  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
- });
1593
+ entries = readdirSync(current);
546
1594
  }
547
- catch { /* skip invalid */ }
1595
+ catch {
1596
+ return;
1597
+ }
1598
+ // Treat .gitkeep as "this folder is intentionally kept" — don't auto-prune.
1599
+ if (entries.length > 0)
1600
+ return;
1601
+ try {
1602
+ rmSync(current, { recursive: true, force: true });
1603
+ }
1604
+ catch {
1605
+ return;
1606
+ }
1607
+ current = dirname(current);
1608
+ }
1609
+ }
1610
+ /**
1611
+ * List entities of a given type (or all types if not specified).
1612
+ *
1613
+ * Walks the entire repo tree so entries in nested folders are discovered.
1614
+ * The type filter is applied against frontmatter `entity_type`; legacy
1615
+ * entries without explicit frontmatter type fall back to the depth-1
1616
+ * plural-type directory name (so `meetings/foo.md` still resolves as a
1617
+ * meeting). Files with no inferable type (README.md, CONTRIBUTING.md, etc.)
1618
+ * are skipped.
1619
+ */
1620
+ list(entityType, limit) {
1621
+ if (!existsSync(this.repoPath))
1622
+ return [];
1623
+ const cacheKey = entityType ?? '*';
1624
+ const cached = this.listCache?.get(cacheKey);
1625
+ if (cached) {
1626
+ return limit && limit > 0 ? cached.slice(0, limit) : cached;
548
1627
  }
1628
+ // Serve a type-filtered request from the unfiltered cache if present —
1629
+ // avoids re-walking the tree for each entity type on the same warm cache.
1630
+ if (entityType) {
1631
+ const all = this.listCache?.get('*');
1632
+ if (all) {
1633
+ const filtered = all.filter(e => e.entityType === entityType);
1634
+ if (!this.listCache)
1635
+ this.listCache = new Map();
1636
+ this.listCache.set(cacheKey, filtered);
1637
+ return limit && limit > 0 ? filtered.slice(0, limit) : filtered;
1638
+ }
1639
+ }
1640
+ const results = [];
1641
+ const seen = new Set(); // "type/id" — dedup if a file ends up indexed twice
1642
+ const validateLenient = (type, data) => {
1643
+ try {
1644
+ return this.validateEntity(type, data);
1645
+ }
1646
+ catch {
1647
+ try {
1648
+ return this.validateEntity(type, { ...data, entity_type: type }, true);
1649
+ }
1650
+ catch {
1651
+ return data;
1652
+ }
1653
+ }
1654
+ };
1655
+ this.walkMarkdownFiles((filePath) => {
1656
+ let doc;
1657
+ try {
1658
+ doc = this.markdownService.parseFile(filePath);
1659
+ }
1660
+ catch {
1661
+ return;
1662
+ }
1663
+ const fmType = doc.frontmatter.entity_type;
1664
+ const inferred = this.inferLegacyType(filePath);
1665
+ const resolvedType = (fmType ?? inferred);
1666
+ if (!resolvedType)
1667
+ return; // README, etc.
1668
+ if (entityType && resolvedType !== entityType)
1669
+ return;
1670
+ const id = filePath.split('/').pop().replace(/\.md$/, '');
1671
+ const key = `${resolvedType}/${id}`;
1672
+ if (seen.has(key))
1673
+ return;
1674
+ seen.add(key);
1675
+ const validatedData = validateLenient(resolvedType, doc.frontmatter);
1676
+ results.push({
1677
+ id,
1678
+ path: filePath,
1679
+ folder_path: folderPathFromFile(filePath, this.repoPath),
1680
+ entityType: resolvedType,
1681
+ data: validatedData,
1682
+ document: doc,
1683
+ });
1684
+ });
1685
+ if (!this.listCache)
1686
+ this.listCache = new Map();
1687
+ this.listCache.set(cacheKey, results);
549
1688
  if (limit && limit > 0)
550
1689
  return results.slice(0, limit);
551
1690
  return results;
@@ -554,7 +1693,7 @@ export class BaseGraphManager {
554
1693
  * Search entities
555
1694
  */
556
1695
  search(options) {
557
- const { entityType, query, filters, visibility, tags, dateRange, sortBy, limit } = options;
1696
+ const { entityType, query, filters, visibility, tags, dateRange, sortBy, limit, nameOnly } = options;
558
1697
  // Get all entities (optionally filtered by type)
559
1698
  let entities = this.list(entityType);
560
1699
  // Apply query filter (search across content + all string frontmatter fields)
@@ -564,7 +1703,7 @@ export class BaseGraphManager {
564
1703
  const lowerQuery = query.toLowerCase();
565
1704
  const EXCLUDED_SEARCH_FIELDS = new Set([
566
1705
  'entity_type', 'entity_id', 'created_by', 'updated_by',
567
- 'created_at', 'updated_at', 'date', 'start_date', 'end_date', 'due_date', 'expected_close',
1706
+ 'created_at', 'updated_at', 'date', 'start_date',
568
1707
  'deal_value', 'email', 'website', 'url', 'link', 'schema',
569
1708
  ]);
570
1709
  scored = [];
@@ -581,8 +1720,8 @@ export class BaseGraphManager {
581
1720
  else if (name.includes(lowerQuery)) {
582
1721
  score = 2;
583
1722
  }
584
- // Frontmatter field match
585
- if (score < 0) {
1723
+ // Frontmatter field match — skipped in nameOnly mode (autosuggest).
1724
+ if (score < 0 && !nameOnly) {
586
1725
  const data = e.data;
587
1726
  for (const [key, val] of Object.entries(data)) {
588
1727
  if (EXCLUDED_SEARCH_FIELDS.has(key) || key === 'name')
@@ -603,8 +1742,11 @@ export class BaseGraphManager {
603
1742
  }
604
1743
  }
605
1744
  }
606
- // Body content match
607
- if (score < 0 && e.document.content.toLowerCase().includes(lowerQuery)) {
1745
+ // Body content match — skipped in nameOnly mode. Scanning every
1746
+ // entity's full markdown body is the dominant cost for keyword
1747
+ // search and is wasted on autosuggest, where the dropdown only
1748
+ // shows the name.
1749
+ if (score < 0 && !nameOnly && e.document.content.toLowerCase().includes(lowerQuery)) {
608
1750
  score = 0;
609
1751
  }
610
1752
  if (score >= 0)
@@ -709,31 +1851,43 @@ export class BaseGraphManager {
709
1851
  }
710
1852
  return entities;
711
1853
  }
1854
+ /**
1855
+ * Resolve a markdown file (anywhere under the repo) to its (type, id),
1856
+ * tolerating both legacy {plural-type}/{id}.md and nested-folder layouts.
1857
+ * Returns undefined for files without a recognizable entity type.
1858
+ */
1859
+ resolveFileToEntity(absPath) {
1860
+ const filename = absPath.split('/').pop();
1861
+ if (!filename || !filename.endsWith('.md'))
1862
+ return undefined;
1863
+ const id = filename.replace(/\.md$/, '');
1864
+ let fmType;
1865
+ try {
1866
+ fmType = this.markdownService.parseFile(absPath).frontmatter.entity_type;
1867
+ }
1868
+ catch { /* unparsable — fall back to inference */ }
1869
+ const type = fmType || this.inferLegacyType(absPath);
1870
+ if (!type)
1871
+ return undefined;
1872
+ return { type: type, id };
1873
+ }
712
1874
  /**
713
1875
  * Get entities related to a given entity (via wikilinks)
714
1876
  */
715
1877
  getRelated(entityType, entityId) {
716
1878
  const entity = this.get(entityType, entityId);
717
1879
  const related = [];
718
- // Find all wikilinked entities
719
1880
  for (const link of entity.document.wikilinks) {
720
- // Try to find the linked entity in all type subfolders
721
1881
  const found = this.markdownService.findEntity(link, [this.repoPath]);
722
- if (found) {
723
- const relPath = found.replace(this.repoPath + '/', '');
724
- const parts = relPath.split('/');
725
- // New layout: {plural-type}/{entityId}.md
726
- if (parts.length === 2 && parts[1].endsWith('.md')) {
727
- const linkedId = parts[1].replace(/\.md$/, '');
728
- try {
729
- const doc = this.markdownService.parseFile(found);
730
- const linkedType = doc.frontmatter.entity_type ?? PLURAL_TO_SINGULAR[parts[0]] ?? this.repoName;
731
- const linkedEntity = this.get(linkedType, linkedId);
732
- related.push(linkedEntity);
733
- }
734
- catch { /* skip invalid */ }
735
- }
1882
+ if (!found)
1883
+ continue;
1884
+ const resolved = this.resolveFileToEntity(found);
1885
+ if (!resolved)
1886
+ continue;
1887
+ try {
1888
+ related.push(this.get(resolved.type, resolved.id));
736
1889
  }
1890
+ catch { /* skip invalid */ }
737
1891
  }
738
1892
  return related;
739
1893
  }
@@ -744,19 +1898,13 @@ export class BaseGraphManager {
744
1898
  const backlinks = this.markdownService.findBacklinks(entityId, [this.repoPath]);
745
1899
  const results = [];
746
1900
  for (const { file } of backlinks) {
747
- const relPath = file.replace(this.repoPath + '/', '');
748
- const parts = relPath.split('/');
749
- // New layout: {plural-type}/{entityId}.md
750
- if (parts.length === 2 && parts[1].endsWith('.md')) {
751
- const linkedId = parts[1].replace(/\.md$/, '');
752
- try {
753
- const doc = this.markdownService.parseFile(file);
754
- const linkedType = doc.frontmatter.entity_type ?? PLURAL_TO_SINGULAR[parts[0]] ?? this.repoName;
755
- const linkedEntity = this.get(linkedType, linkedId);
756
- results.push(linkedEntity);
757
- }
758
- catch { /* skip invalid */ }
1901
+ const resolved = this.resolveFileToEntity(file);
1902
+ if (!resolved)
1903
+ continue;
1904
+ try {
1905
+ results.push(this.get(resolved.type, resolved.id));
759
1906
  }
1907
+ catch { /* skip invalid */ }
760
1908
  }
761
1909
  return results;
762
1910
  }
@@ -771,28 +1919,22 @@ export class BaseGraphManager {
771
1919
  * Refactor a wikilink (rename entity and update all references)
772
1920
  */
773
1921
  async refactorWikilink(oldId, newId, commitMessage) {
774
- // Find all files that reference the old entity ID
775
1922
  const backlinks = this.markdownService.findBacklinks(oldId, [this.repoPath]);
776
- // Update each file
777
1923
  if (this.gitService) {
778
1924
  const transaction = this.gitService.createTransaction();
779
1925
  for (const { file } of backlinks) {
780
1926
  this.markdownService.updateWikilinks(file, oldId, newId);
781
- const relPath = file.replace(this.repoPath + '/', '');
782
- const parts = relPath.split('/');
783
- // New layout: {plural-type}/{entityId}.md
784
- if (parts.length === 2 && parts[1].endsWith('.md')) {
785
- const entityId = parts[1].replace(/\.md$/, '');
786
- const doc = this.markdownService.parseFile(file);
787
- transaction.add({
788
- operation: 'update',
789
- entityType: doc.frontmatter.entity_type ?? PLURAL_TO_SINGULAR[parts[0]] ?? parts[0],
790
- entityId,
791
- filePath: file,
792
- });
793
- }
1927
+ const resolved = this.resolveFileToEntity(file);
1928
+ if (!resolved)
1929
+ continue;
1930
+ transaction.add({
1931
+ operation: 'update',
1932
+ entityType: resolved.type,
1933
+ entityId: resolved.id,
1934
+ filePath: file,
1935
+ });
794
1936
  }
795
- const message = commitMessage || `Refactor wikilink: ${oldId} → ${newId}`;
1937
+ const message = commitMessage || commitMsg.relinked(oldId, newId);
796
1938
  await transaction.commit(this.gitUser, message);
797
1939
  }
798
1940
  else {
@@ -802,14 +1944,754 @@ export class BaseGraphManager {
802
1944
  }
803
1945
  return { updated: backlinks.length };
804
1946
  }
1947
+ // ────────────────────────────────────────────────────────────────────────────
1948
+ // Folder CRUD — operate on directories within the repo, not on entries.
1949
+ // Sharing remains a repo-level concept; folders carry no permissions or
1950
+ // metadata of their own. Empty folders are kept around via .gitkeep so git
1951
+ // tracks them.
1952
+ // ────────────────────────────────────────────────────────────────────────────
1953
+ /**
1954
+ * List every non-empty folder in this repo, plus folders explicitly kept
1955
+ * via .gitkeep. Returns repo-relative POSIX paths, sorted, deduplicated.
1956
+ * The repo root ("") is intentionally omitted — it's implicit.
1957
+ */
1958
+ listFolders() {
1959
+ if (!existsSync(this.repoPath))
1960
+ return [];
1961
+ const folders = new Set();
1962
+ const sidecarByPath = new Map();
1963
+ const sidecarIdByPath = new Map();
1964
+ const stack = [this.repoPath];
1965
+ const repoRoot = this.repoPath.replace(/\/+$/, '');
1966
+ while (stack.length > 0) {
1967
+ const dir = stack.pop();
1968
+ let entries;
1969
+ try {
1970
+ entries = readdirSync(dir);
1971
+ }
1972
+ catch {
1973
+ continue;
1974
+ }
1975
+ let kept = false;
1976
+ let hasSidecar = false;
1977
+ for (const entry of entries) {
1978
+ if (entry === '.gitkeep')
1979
+ kept = true;
1980
+ if (entry === FOLDER_SIDECAR)
1981
+ hasSidecar = true;
1982
+ if (entry.startsWith('.'))
1983
+ continue;
1984
+ if (WALK_IGNORE.has(entry))
1985
+ continue;
1986
+ const full = join(dir, entry);
1987
+ let st;
1988
+ try {
1989
+ st = statSync(full);
1990
+ }
1991
+ catch {
1992
+ continue;
1993
+ }
1994
+ if (st.isDirectory())
1995
+ stack.push(full);
1996
+ }
1997
+ if (dir === repoRoot)
1998
+ continue;
1999
+ // Include this directory if it has any .md files OR a .gitkeep marker
2000
+ // OR a sidecar (which carries display metadata even for empty folders).
2001
+ const hasMarkdown = entries.some(e => e.endsWith('.md') && !e.startsWith('.'));
2002
+ if (hasMarkdown || kept || hasSidecar) {
2003
+ const folderPath = folderPathFromFile(join(dir, 'placeholder.md'), this.repoPath);
2004
+ folders.add(folderPath);
2005
+ if (hasSidecar) {
2006
+ const data = readFolderSidecarData(dir);
2007
+ if (data?.display_name)
2008
+ sidecarByPath.set(folderPath, data.display_name);
2009
+ if (data?.id)
2010
+ sidecarIdByPath.set(folderPath, data.id);
2011
+ }
2012
+ }
2013
+ }
2014
+ return [...folders]
2015
+ .filter(p => p.length > 0)
2016
+ .sort()
2017
+ .map(p => {
2018
+ const entry = { path: p, name: p.slice(p.lastIndexOf('/') + 1) };
2019
+ const display = sidecarByPath.get(p);
2020
+ if (display)
2021
+ entry.display_name = display;
2022
+ const id = sidecarIdByPath.get(p);
2023
+ if (id)
2024
+ entry.id = id;
2025
+ return entry;
2026
+ });
2027
+ }
2028
+ /**
2029
+ * Read the display_name override for a single folder, or null if no sidecar
2030
+ * exists / is unreadable. Used by mutation paths that need the current
2031
+ * override before deciding whether to rewrite it.
2032
+ */
2033
+ readFolderDisplayName(folderPath) {
2034
+ const normalized = validateFolderPath(folderPath);
2035
+ if (!normalized)
2036
+ return null;
2037
+ return readFolderSidecar(join(this.repoPath, normalized));
2038
+ }
2039
+ /**
2040
+ * Write or remove the per-folder display_name sidecar. Pass `null` /
2041
+ * empty-string to remove (returns to slug fallback). Returns the absolute
2042
+ * path of the sidecar (for git staging) when one was written or removed,
2043
+ * or null when no change was needed (idempotent).
2044
+ *
2045
+ * Caller is responsible for committing — the helper only touches the
2046
+ * filesystem. Folder must already exist; this never creates one.
2047
+ */
2048
+ writeFolderDisplayName(folderPath, displayName) {
2049
+ const normalized = validateFolderPath(folderPath);
2050
+ if (!normalized) {
2051
+ throw new InvalidFolderPathError('Folder path cannot be empty');
2052
+ }
2053
+ const abs = join(this.repoPath, normalized);
2054
+ if (!existsSync(abs)) {
2055
+ throw new Error(`Folder not found: ${normalized}`);
2056
+ }
2057
+ const trimmed = displayName?.trim();
2058
+ if (!trimmed) {
2059
+ // Clear the override but keep the sidecar (and its id) when one exists —
2060
+ // writeFolderSidecarData drops display_name and removes the file only if
2061
+ // nothing (no id) remains.
2062
+ const existing = readFolderSidecar(abs);
2063
+ if (existing === null)
2064
+ return null; // nothing to clear
2065
+ const result = writeFolderSidecarData(abs, { display_name: undefined });
2066
+ this.invalidateIndex();
2067
+ return result;
2068
+ }
2069
+ const existing = readFolderSidecar(abs);
2070
+ if (existing === trimmed)
2071
+ return null;
2072
+ const sidecarPath = writeFolderSidecar(abs, trimmed);
2073
+ this.invalidateIndex();
2074
+ return sidecarPath;
2075
+ }
2076
+ /**
2077
+ * Bulk-write display_name sidecars for many folders at once. Used by the
2078
+ * import path to record original-casing overrides for every nested
2079
+ * directory it slugified. First-writer-wins: existing sidecars are NOT
2080
+ * overwritten, so a second import doesn't clobber a user's manual rename.
2081
+ *
2082
+ * Returns the list of sidecar paths actually written (for git staging).
2083
+ * Skipped paths (folder missing, empty display, display_name override
2084
+ * already set) are quietly omitted.
2085
+ */
2086
+ recordFolderDisplays(entries) {
2087
+ const written = [];
2088
+ for (const entry of entries) {
2089
+ const normalized = validateFolderPath(entry.path);
2090
+ if (!normalized)
2091
+ continue;
2092
+ const abs = join(this.repoPath, normalized);
2093
+ if (!existsSync(abs))
2094
+ continue;
2095
+ // First-writer-wins on the display_name specifically — skip when an
2096
+ // override already exists (don't clobber a manual rename), but still
2097
+ // record onto an id-only sidecar (every folder now has one for its id).
2098
+ if (readFolderSidecar(abs) !== null)
2099
+ continue;
2100
+ const display = entry.display_name.trim();
2101
+ if (!display)
2102
+ continue;
2103
+ // Merge-safe: preserves the folder's id alongside the new display_name.
2104
+ written.push(writeFolderSidecar(abs, display));
2105
+ }
2106
+ if (written.length > 0)
2107
+ this.invalidateIndex();
2108
+ return written;
2109
+ }
2110
+ /**
2111
+ * Create a folder at the given path (mkdir -p semantics) and drop a
2112
+ * .gitkeep so git tracks it even when empty. Validates the path. No-op if
2113
+ * the folder already exists; .gitkeep is added if missing.
2114
+ */
2115
+ async createFolder(folderPath, commitMessage, user, options) {
2116
+ const normalized = validateFolderPath(folderPath);
2117
+ if (!normalized) {
2118
+ throw new InvalidFolderPathError('Folder path cannot be empty (use the repo root directly)');
2119
+ }
2120
+ const abs = join(this.repoPath, normalized);
2121
+ mkdirSync(abs, { recursive: true });
2122
+ const keep = join(abs, '.gitkeep');
2123
+ if (!existsSync(keep)) {
2124
+ writeFileSync(keep, '');
2125
+ }
2126
+ // Sidecar is written here (not after the commit) so it joins the same
2127
+ // commit as .gitkeep — folder creation is atomic from git's perspective.
2128
+ // A stable id is assigned eagerly when the folder doesn't already have one;
2129
+ // an existing id is never regenerated (createFolder doubles as an
2130
+ // ensure-folder no-op for paths that already exist).
2131
+ const sidecarPatch = {};
2132
+ if (!readFolderSidecarId(abs))
2133
+ sidecarPatch.id = randomUUID();
2134
+ if (options?.displayName !== undefined)
2135
+ sidecarPatch.display_name = options.displayName;
2136
+ const sidecarPath = Object.keys(sidecarPatch).length > 0
2137
+ ? writeFolderSidecarData(abs, sidecarPatch)
2138
+ : null;
2139
+ this.invalidateIndex();
2140
+ if (this.gitService) {
2141
+ const message = commitMessage || commitMsg.folderCreated(normalized);
2142
+ const files = sidecarPath ? [keep, sidecarPath] : [keep];
2143
+ this.gitService.commitFiles(files, message, user ?? this.gitUser);
2144
+ }
2145
+ }
2146
+ /**
2147
+ * Rename / move a folder within the repo. Moves the entire subtree
2148
+ * atomically via filesystem rename. Refuses if the target path is already
2149
+ * occupied.
2150
+ */
2151
+ async renameFolder(fromPath, toPath, commitMessage, user, options) {
2152
+ const fromNormalized = validateFolderPath(fromPath);
2153
+ const toNormalized = validateFolderPath(toPath);
2154
+ if (!fromNormalized)
2155
+ throw new InvalidFolderPathError('Source folder path required');
2156
+ if (!toNormalized)
2157
+ throw new InvalidFolderPathError('Target folder path required');
2158
+ // Display-only update: same path, just rewrite the sidecar.
2159
+ if (fromNormalized === toNormalized) {
2160
+ if (options?.displayName === undefined)
2161
+ return;
2162
+ const sidecarPath = this.writeFolderDisplayName(fromNormalized, options.displayName);
2163
+ if (sidecarPath && this.gitService) {
2164
+ const message = commitMessage || commitMsg.folderDisplayRenamed(fromNormalized);
2165
+ this.gitService.commitFiles([sidecarPath], message, user ?? this.gitUser);
2166
+ }
2167
+ return;
2168
+ }
2169
+ const fromAbs = join(this.repoPath, fromNormalized);
2170
+ const toAbs = join(this.repoPath, toNormalized);
2171
+ if (!existsSync(fromAbs)) {
2172
+ throw new Error(`Folder not found: ${fromNormalized}`);
2173
+ }
2174
+ if (existsSync(toAbs)) {
2175
+ throw new Error(`Target folder already exists: ${toNormalized}`);
2176
+ }
2177
+ mkdirSync(dirname(toAbs), { recursive: true });
2178
+ const { renameSync } = await import('fs');
2179
+ renameSync(fromAbs, toAbs);
2180
+ // The sidecar moved with the folder via FS rename. Update it now if the
2181
+ // caller passed a new display name (or `null` to clear). Idempotent — no
2182
+ // sidecar write happens when display matches the existing override.
2183
+ let sidecarPath = null;
2184
+ if (options?.displayName !== undefined) {
2185
+ sidecarPath = this.writeFolderDisplayName(toNormalized, options.displayName);
2186
+ }
2187
+ this.invalidateIndex();
2188
+ if (this.gitService) {
2189
+ const message = commitMessage || commitMsg.folderRenamed(fromNormalized, toNormalized);
2190
+ const files = sidecarPath ? [fromAbs, toAbs, sidecarPath] : [fromAbs, toAbs];
2191
+ this.gitService.commitFiles(files, message, user ?? this.gitUser);
2192
+ }
2193
+ await this.pruneEmptyAncestors(dirname(fromAbs));
2194
+ }
2195
+ /**
2196
+ * Move a folder (and its full subtree) into a different repo at `targetFolderPath`.
2197
+ *
2198
+ * The naive way to do this is to iterate the source's entries and call
2199
+ * `transfer` per entry — that's what the web UI used to do, and it costs
2200
+ * 2 git commits per entry (1600 commits for 800 files), each blocking the
2201
+ * Node event loop via `execSync`. This method performs the move as a
2202
+ * single FS-level rename + one commit per repo, regardless of how many
2203
+ * files are inside.
2204
+ *
2205
+ * Returns the count of entries + subfolders moved so the caller can
2206
+ * surface progress.
2207
+ */
2208
+ async moveFolderToRepo(sourceFolderPath, target, targetFolderPath, options) {
2209
+ const sourceNormalized = validateFolderPath(sourceFolderPath);
2210
+ if (!sourceNormalized) {
2211
+ throw new InvalidFolderPathError('Source folder path required');
2212
+ }
2213
+ const targetNormalized = targetFolderPath ? validateFolderPath(targetFolderPath) : '';
2214
+ if (targetFolderPath && !targetNormalized) {
2215
+ throw new InvalidFolderPathError('Invalid target folder path');
2216
+ }
2217
+ if (target === this && sourceNormalized === targetNormalized) {
2218
+ return { entriesDeleted: 0, subfoldersDeleted: 0 };
2219
+ }
2220
+ const sourceAbs = join(this.repoPath, sourceNormalized);
2221
+ if (!existsSync(sourceAbs)) {
2222
+ throw new Error(`Source folder not found: ${sourceNormalized}`);
2223
+ }
2224
+ const targetAbs = targetNormalized
2225
+ ? join(target.repoPath, targetNormalized)
2226
+ : target.repoPath;
2227
+ // Refuse if target already has visible content — caller must move into
2228
+ // an empty / nonexistent folder. (Hidden files like .studiograph are OK.)
2229
+ if (existsSync(targetAbs)) {
2230
+ const visible = readdirSync(targetAbs).filter(n => !n.startsWith('.'));
2231
+ if (visible.length > 0) {
2232
+ throw new Error(`Target folder is not empty: ${targetNormalized || '/'}`);
2233
+ }
2234
+ }
2235
+ const stats = this.countFolderContents(sourceAbs);
2236
+ // For a cross-repo move, capture the entities in the tree BEFORE the
2237
+ // rename so we can re-point their asset ownership to the target repo
2238
+ // afterward (serve-time authz reads the index's repo column).
2239
+ const ownersToReassign = [];
2240
+ if (this.assetIndex && target.repoName !== this.repoName) {
2241
+ const stack = [sourceAbs];
2242
+ while (stack.length > 0) {
2243
+ const dir = stack.pop();
2244
+ let names;
2245
+ try {
2246
+ names = readdirSync(dir);
2247
+ }
2248
+ catch {
2249
+ continue;
2250
+ }
2251
+ for (const name of names) {
2252
+ if (name.startsWith('.') || WALK_IGNORE.has(name))
2253
+ continue;
2254
+ const full = join(dir, name);
2255
+ let st;
2256
+ try {
2257
+ st = statSync(full);
2258
+ }
2259
+ catch {
2260
+ continue;
2261
+ }
2262
+ if (st.isDirectory())
2263
+ stack.push(full);
2264
+ else if (st.isFile() && name.endsWith('.md')) {
2265
+ const ent = this.identifyEntityAtPath(full);
2266
+ if (ent)
2267
+ ownersToReassign.push({ entityType: ent.entityType, entityId: ent.entityId });
2268
+ }
2269
+ }
2270
+ }
2271
+ }
2272
+ // Atomic FS rename if possible. Falls back to copy + delete if the two
2273
+ // repos sit on different filesystems (rare, but possible on Docker
2274
+ // bind mounts).
2275
+ const { renameSync, cpSync, rmSync } = await import('fs');
2276
+ mkdirSync(dirname(targetAbs), { recursive: true });
2277
+ if (existsSync(targetAbs)) {
2278
+ // Empty pre-existing dir — drop it so renameSync can take its place.
2279
+ rmSync(targetAbs, { recursive: true, force: true });
2280
+ }
2281
+ try {
2282
+ renameSync(sourceAbs, targetAbs);
2283
+ }
2284
+ catch (err) {
2285
+ if (err?.code === 'EXDEV') {
2286
+ cpSync(sourceAbs, targetAbs, { recursive: true });
2287
+ rmSync(sourceAbs, { recursive: true, force: true });
2288
+ }
2289
+ else {
2290
+ throw err;
2291
+ }
2292
+ }
2293
+ this.invalidateIndex();
2294
+ target.invalidateIndex();
2295
+ const user = options?.user ?? this.gitUser;
2296
+ const sourceMessage = options?.commitMessage
2297
+ ?? commitMsg.folderMoved(sourceNormalized, target.repoName, targetNormalized);
2298
+ const targetMessage = options?.commitMessage
2299
+ ?? commitMsg.folderReceived(this.repoName, sourceNormalized, targetNormalized);
2300
+ if (this.gitService) {
2301
+ this.gitService.commitFiles([sourceAbs], sourceMessage, user);
2302
+ }
2303
+ if (target.gitService) {
2304
+ target.gitService.commitFiles([targetAbs], targetMessage, user);
2305
+ }
2306
+ // Cross-repo: re-point asset ownership to the target repo (see capture above).
2307
+ for (const o of ownersToReassign) {
2308
+ this.assetIndex?.reassignOwner(this.repoName, o.entityType, o.entityId, { repo: target.repoName });
2309
+ }
2310
+ await this.pruneEmptyAncestors(dirname(sourceAbs));
2311
+ return stats;
2312
+ }
2313
+ /**
2314
+ * Move a batch of entries (potentially across many source folders) into a
2315
+ * single target folder, optionally in a different repo. The web UI's
2316
+ * multi-select bulk move used to call /move or /transfer per entry — that
2317
+ * was 1-2 sync git commits per entry, blocking the event loop on each one.
2318
+ * This collapses the operation to a single FS rename per file plus one
2319
+ * commit on each repo touched.
2320
+ */
2321
+ async moveEntitiesToRepo(entries, target, targetFolderPath, options) {
2322
+ if (entries.length === 0)
2323
+ return { moved: 0 };
2324
+ const targetNormalized = targetFolderPath ? validateFolderPath(targetFolderPath) : '';
2325
+ if (targetFolderPath && !targetNormalized) {
2326
+ throw new InvalidFolderPathError('Invalid target folder path');
2327
+ }
2328
+ const targetDir = targetNormalized
2329
+ ? join(target.repoPath, targetNormalized)
2330
+ : target.repoPath;
2331
+ // Resolve all source paths up front and validate target conflicts before
2332
+ // any FS work — partial moves on conflict would leave the user with a
2333
+ // confusing half-state.
2334
+ const moves = [];
2335
+ for (const entry of entries) {
2336
+ const existing = this.get(entry.entityType, entry.entityId);
2337
+ const dst = join(targetDir, `${entry.entityId}.md`);
2338
+ if (existing.path === dst)
2339
+ continue; // already there — no-op
2340
+ if (existsSync(dst)) {
2341
+ throw new Error(`Path already occupied: ${target.repoName}:${targetNormalized ? targetNormalized + '/' : ''}${entry.entityId}.md`);
2342
+ }
2343
+ moves.push({ src: existing.path, dst });
2344
+ }
2345
+ if (moves.length === 0)
2346
+ return { moved: 0 };
2347
+ const { renameSync, cpSync } = await import('fs');
2348
+ mkdirSync(targetDir, { recursive: true });
2349
+ for (const m of moves) {
2350
+ try {
2351
+ renameSync(m.src, m.dst);
2352
+ }
2353
+ catch (err) {
2354
+ if (err?.code === 'EXDEV') {
2355
+ cpSync(m.src, m.dst);
2356
+ rmSync(m.src);
2357
+ }
2358
+ else {
2359
+ throw err;
2360
+ }
2361
+ }
2362
+ }
2363
+ // Folder lifecycle: ensure markers in every source dir we may have
2364
+ // emptied, drop the marker in the (single) target dir now that it has
2365
+ // .md children. Helpers no-op for the repo root and for paths that
2366
+ // are already in the right state, so calling them per dir is cheap.
2367
+ const sourceMarkers = [];
2368
+ const visitedSourceDirs = new Set();
2369
+ for (const m of moves) {
2370
+ const sourceDir = dirname(m.src);
2371
+ if (visitedSourceDirs.has(sourceDir))
2372
+ continue;
2373
+ visitedSourceDirs.add(sourceDir);
2374
+ const added = this.ensureFolderMarker(sourceDir);
2375
+ if (added)
2376
+ sourceMarkers.push(added);
2377
+ }
2378
+ const removedTargetMarker = target.removeFolderMarker(targetDir);
2379
+ this.invalidateIndex();
2380
+ if (target !== this)
2381
+ target.invalidateIndex();
2382
+ const user = options?.user ?? this.gitUser;
2383
+ const where = target === this
2384
+ ? (targetNormalized || '(root)')
2385
+ : `${target.repoName}:${targetNormalized || '(root)'}`;
2386
+ const sourceMessage = options?.commitMessage ?? commitMsg.movedBatch(moves.length, where);
2387
+ if (this.gitService) {
2388
+ const sourcePaths = [...moves.map(m => m.src), ...sourceMarkers];
2389
+ const dstStaging = target === this
2390
+ ? [...moves.map(m => m.dst), ...(removedTargetMarker ? [removedTargetMarker] : [])]
2391
+ : [];
2392
+ this.gitService.commitFiles([...sourcePaths, ...dstStaging], sourceMessage, user);
2393
+ }
2394
+ if (target !== this && target.gitService) {
2395
+ const targetMessage = options?.commitMessage
2396
+ ?? commitMsg.receivedBatch(moves.length, this.repoName);
2397
+ const targetPaths = [...moves.map(m => m.dst), ...(removedTargetMarker ? [removedTargetMarker] : [])];
2398
+ target.gitService.commitFiles(targetPaths, targetMessage, user);
2399
+ }
2400
+ // Cross-repo move: re-point asset ownership to the target repo so the
2401
+ // serve route's per-repo authz resolves against the new repo. No blob
2402
+ // movement (keys are immutable); the index is workspace-global so source
2403
+ // and target share it. Same-repo folder moves leave repo unchanged.
2404
+ if (target.repoName !== this.repoName) {
2405
+ for (const entry of entries) {
2406
+ this.assetIndex?.reassignOwner(this.repoName, entry.entityType, entry.entityId, { repo: target.repoName });
2407
+ }
2408
+ }
2409
+ return { moved: moves.length };
2410
+ }
2411
+ /**
2412
+ * Delete a folder. Refuses non-empty folders unless `recursive: true`.
2413
+ * Returns the count of entries + subfolders removed (for blast-radius UI).
2414
+ */
2415
+ async deleteFolder(folderPath, options) {
2416
+ const normalized = validateFolderPath(folderPath);
2417
+ if (!normalized) {
2418
+ throw new InvalidFolderPathError('Cannot delete the repo root via deleteFolder');
2419
+ }
2420
+ const abs = join(this.repoPath, normalized);
2421
+ if (!existsSync(abs)) {
2422
+ throw new Error(`Folder not found: ${normalized}`);
2423
+ }
2424
+ const stats = this.countFolderContents(abs);
2425
+ if (!options?.recursive && (stats.entriesDeleted > 0 || stats.subfoldersDeleted > 0)) {
2426
+ const err = new Error(`Folder is not empty: ${normalized} (${stats.entriesDeleted} entries, ${stats.subfoldersDeleted} subfolders). Pass recursive: true to delete contents.`);
2427
+ err.code = 'FOLDER_NOT_EMPTY';
2428
+ err.blastRadius = stats;
2429
+ throw err;
2430
+ }
2431
+ // Collect the entities in the tree BEFORE removing it, so we can clean
2432
+ // their immutable-keyed assets (the rmSync below takes the whole subtree
2433
+ // at once, so per-entity (type,id) must be captured first).
2434
+ const ownersToClean = [];
2435
+ if (this.assetIndex) {
2436
+ const stack = [abs];
2437
+ while (stack.length > 0) {
2438
+ const dir = stack.pop();
2439
+ let entries;
2440
+ try {
2441
+ entries = readdirSync(dir);
2442
+ }
2443
+ catch {
2444
+ continue;
2445
+ }
2446
+ for (const entry of entries) {
2447
+ if (entry.startsWith('.') || WALK_IGNORE.has(entry))
2448
+ continue;
2449
+ const full = join(dir, entry);
2450
+ let st;
2451
+ try {
2452
+ st = statSync(full);
2453
+ }
2454
+ catch {
2455
+ continue;
2456
+ }
2457
+ if (st.isDirectory())
2458
+ stack.push(full);
2459
+ else if (st.isFile() && entry.endsWith('.md')) {
2460
+ const ent = this.identifyEntityAtPath(full);
2461
+ if (ent)
2462
+ ownersToClean.push({ entityType: ent.entityType, entityId: ent.entityId });
2463
+ }
2464
+ }
2465
+ }
2466
+ }
2467
+ const { rmSync } = await import('fs');
2468
+ rmSync(abs, { recursive: true, force: true });
2469
+ this.invalidateIndex();
2470
+ if (this.gitService) {
2471
+ const message = options?.commitMessage || commitMsg.folderDeleted(normalized);
2472
+ this.gitService.commitFiles([abs], message, options?.user ?? this.gitUser);
2473
+ }
2474
+ for (const o of ownersToClean) {
2475
+ await this.cleanupOwnedAssets(o.entityType, o.entityId);
2476
+ }
2477
+ await this.pruneEmptyAncestors(dirname(abs));
2478
+ return stats;
2479
+ }
2480
+ /**
2481
+ * Count the entries (.md files with entity_type) and subfolders inside an
2482
+ * absolute directory, recursively. Used to surface blast radius in the
2483
+ * delete-folder approval UI.
2484
+ */
2485
+ countFolderContents(absDir) {
2486
+ let entriesDeleted = 0;
2487
+ const subfolders = new Set();
2488
+ const stack = [absDir];
2489
+ while (stack.length > 0) {
2490
+ const dir = stack.pop();
2491
+ let entries;
2492
+ try {
2493
+ entries = readdirSync(dir);
2494
+ }
2495
+ catch {
2496
+ continue;
2497
+ }
2498
+ for (const entry of entries) {
2499
+ if (entry.startsWith('.'))
2500
+ continue;
2501
+ if (WALK_IGNORE.has(entry))
2502
+ continue;
2503
+ const full = join(dir, entry);
2504
+ let st;
2505
+ try {
2506
+ st = statSync(full);
2507
+ }
2508
+ catch {
2509
+ continue;
2510
+ }
2511
+ if (st.isDirectory()) {
2512
+ subfolders.add(full);
2513
+ stack.push(full);
2514
+ }
2515
+ else if (st.isFile() && entry.endsWith('.md')) {
2516
+ entriesDeleted += 1;
2517
+ }
2518
+ }
2519
+ }
2520
+ return { entriesDeleted, subfoldersDeleted: subfolders.size };
2521
+ }
2522
+ /**
2523
+ * Return the storage backend the graph is bound to, or `undefined` if
2524
+ * no asset service is configured. Used by routes that need to branch
2525
+ * between local-stream and R2-redirect behavior.
2526
+ */
2527
+ getAssetStorage() {
2528
+ return this.assetService?.getStorage();
2529
+ }
2530
+ /** The asset storage service (raw key copy/exists), if configured. Used by
2531
+ * the one-time key migration, which operates on raw `{...}/{file}` keys. */
2532
+ getAssetService() {
2533
+ return this.assetService;
2534
+ }
2535
+ /**
2536
+ * Download the bytes of an asset OWNED by `assetEntityId` in THIS repo.
2537
+ * Used by the convert flow to turn a stored .docx/.xlsx into a derived
2538
+ * note/dataset. Backend-agnostic (local reads disk, R2 GetObjects).
2539
+ *
2540
+ * SECURITY: the asset's `asset_url` frontmatter is attacker-influenceable
2541
+ * (a folder member can create an `asset` entity whose url points at another
2542
+ * folder's storage key). So ownership is NOT taken from the url — it's
2543
+ * resolved through the authoritative asset index, scoped to
2544
+ * `(this.repoName, 'asset', assetEntityId)`, exactly like the GET
2545
+ * /api/assets serve route. The url is used only to disambiguate which of the
2546
+ * entity's own files to fetch (by filename). Unindexed legacy assets fall
2547
+ * back to the legacy `asset/{entityId}/{file}` key bound to this same entity
2548
+ * id; anything else is refused.
2549
+ */
2550
+ /**
2551
+ * Download an immutable-keyed asset's bytes by its assetId + filename. The
2552
+ * blob store is workspace-global (the key `{assetId}/{filename}` is
2553
+ * independent of repo), so any graph can fetch any asset; the caller — the
2554
+ * caption sweep — scopes to the rows it enumerated from assets.db.
2555
+ */
2556
+ async downloadAssetById(assetId, filename) {
2557
+ if (!this.assetService) {
2558
+ throw new Error('Asset service is not configured for this graph');
2559
+ }
2560
+ return await this.assetService.download(`${assetId}/${filename}`);
2561
+ }
2562
+ /** Pre-signed URL for an immutable-key asset ({assetId}/{filename}). */
2563
+ async getAssetSignedUrlByAssetId(assetId, filename, method = 'GET', expiresIn) {
2564
+ if (!this.assetService) {
2565
+ throw new Error('Asset service is not configured for this graph');
2566
+ }
2567
+ return await this.assetService.getSignedUrlByAssetId(assetId, filename, expiresIn, method);
2568
+ }
2569
+ /**
2570
+ * Mint a short-lived pre-signed URL for an asset. Only supported by
2571
+ * remote backends (R2); the local backend throws. `method` selects
2572
+ * the verb the URL is signed against — HEAD callers need a HEAD URL.
2573
+ */
2574
+ async getAssetSignedUrl(entityType, entityId, filename, keyPrefix, expiresIn, method = 'GET') {
2575
+ if (!this.assetService) {
2576
+ throw new Error('Asset service is not configured for this graph');
2577
+ }
2578
+ return await this.assetService.getSignedUrl(entityType, entityId, filename, keyPrefix, expiresIn, method);
2579
+ }
805
2580
  /**
806
2581
  * Upload asset for an entity
807
2582
  */
808
- async uploadAsset(entityType, entityId, filename, buffer, keyPrefix) {
2583
+ async uploadAsset(entityType, entityId, filename, buffer, keyPrefix,
2584
+ /** Auth user id of the uploader (web). Recorded in the asset index for
2585
+ * the "delete by uploader + workspace owner" rule. Omit for CLI/import/
2586
+ * agent uploads — stored as null (unknown → owner-only delete). */
2587
+ opts) {
809
2588
  if (!this.assetService) {
810
2589
  throw new Error('Asset service is not configured for this graph');
811
2590
  }
812
- return await this.assetService.upload(entityType, entityId, filename, buffer, keyPrefix);
2591
+ // Immutable-key path: generate a permanent assetId, store under
2592
+ // {assetId}/{filename}, and record authoritative ownership in the index.
2593
+ // The owning entity (entityType, entityId) is metadata only — renaming or
2594
+ // retyping the entity never moves the blob. `keyPrefix` is irrelevant here
2595
+ // (it only shaped the legacy {prefix}/{id} key).
2596
+ if (this.assetIndex) {
2597
+ const assetId = randomUUID();
2598
+ const info = await this.assetService.uploadByAssetId(assetId, filename, buffer);
2599
+ this.assetIndex.put({
2600
+ assetId,
2601
+ repo: this.repoName,
2602
+ // Free-standing owner override (folder/workspace) when supplied, else
2603
+ // the embedding entity (current/default behavior for editor embeds).
2604
+ ownerType: opts?.owner?.type ?? entityType,
2605
+ ownerId: opts?.owner?.id ?? entityId,
2606
+ filename: info.filename,
2607
+ contentType: info.contentType,
2608
+ mediaType: info.mediaType,
2609
+ name: opts?.name ?? filename,
2610
+ uploaderUserId: opts?.uploaderUserId ?? null,
2611
+ size: buffer.length,
2612
+ });
2613
+ // Caption/OCR trigger (asset-as-embed): for an IMAGE row, seed a pending
2614
+ // index-state row keyed by this assetId + notify the live caption drain.
2615
+ // Fire-and-forget + image-gated inside ensureInitialAssetIndexState —
2616
+ // captioning must never block or fail the upload. Covers every path that
2617
+ // funnels through uploadAsset: import (folder-owned), manager upload, and
2618
+ // editor embeds (entity-owned).
2619
+ this.vectorService?.ensureInitialAssetIndexState(assetId, {
2620
+ mediaType: info.mediaType,
2621
+ filename: info.filename,
2622
+ repo: this.repoName,
2623
+ }).catch(() => { });
2624
+ return { ...info, assetId, publicUrl: publicUrlForAssetId(assetId, info.filename) };
2625
+ }
2626
+ // Legacy fallback (no index wired — tests/CLI): {type|prefix}/{id}/{file}.
2627
+ const info = await this.assetService.upload(entityType, entityId, filename, buffer, keyPrefix);
2628
+ return { ...info, publicUrl: publicUrlFor(info, keyPrefix || entityType, entityId) };
2629
+ }
2630
+ /** Inject the workspace-global asset ownership index (WorkspaceManager). */
2631
+ setAssetIndex(index) {
2632
+ this.assetIndex = index;
2633
+ }
2634
+ /** The asset ownership index, if wired. */
2635
+ getAssetIndex() {
2636
+ return this.assetIndex;
2637
+ }
2638
+ /**
2639
+ * Remove every immutable-keyed asset owned by (entityType, entityId): the
2640
+ * blob + its index row. Used by delete/bulkDelete/deleteFolder. Blob errors
2641
+ * are non-fatal (markdown is the system of record). No-op without an index.
2642
+ * Owner-scoped, so it only touches THIS entity's assets — a blob another
2643
+ * entity also embeds (copied URL) is not enumerated here.
2644
+ */
2645
+ async cleanupOwnedAssets(entityType, entityId) {
2646
+ if (!this.assetIndex || !this.assetService)
2647
+ return;
2648
+ for (const row of this.assetIndex.listByOwner(this.repoName, entityType, entityId)) {
2649
+ try {
2650
+ await this.assetService.deleteByAssetId(row.assetId, row.filename);
2651
+ }
2652
+ catch (err) {
2653
+ console.warn(`[asset] delete blob ${row.assetId} failed: ${err?.message ?? err}`);
2654
+ }
2655
+ this.assetIndex.delete(row.assetId);
2656
+ // Reap the caption index-state row too (assetId-keyed). Without this an
2657
+ // entity-owned image's state row outlives its blob + assets.db row and
2658
+ // accumulates in the LanceDB side table (read in full on every sweep).
2659
+ void this.vectorService?.deleteIndexStateSafe(row.assetId);
2660
+ }
2661
+ }
2662
+ /** Delete a single index-tracked asset by its immutable id: blob + index row.
2663
+ * The asset-manager delete path for free-standing (folder/workspace-owned)
2664
+ * assets, which have no owning entity to route an entity-coordinate delete
2665
+ * through. Blob errors are non-fatal (the index row is the manager's source
2666
+ * of truth, same as cleanupOwnedAssets). No-op without an index/service. */
2667
+ async deleteAssetById(assetId, filename) {
2668
+ if (!this.assetIndex || !this.assetService)
2669
+ return;
2670
+ try {
2671
+ await this.assetService.deleteByAssetId(assetId, filename);
2672
+ }
2673
+ catch (err) {
2674
+ console.warn(`[asset] delete blob ${assetId} failed: ${err?.message ?? err}`);
2675
+ }
2676
+ // Isolated like the blob delete: when this runs from the import-failure
2677
+ // rollback, a throw here would mask the original import error the caller
2678
+ // must surface (mirrors the old rollback helper).
2679
+ try {
2680
+ this.assetIndex.delete(assetId);
2681
+ }
2682
+ catch (err) {
2683
+ console.warn(`[asset] delete index row ${assetId} failed: ${err?.message ?? err}`);
2684
+ }
2685
+ // Reap the caption index-state row (assetId-keyed). Fire-and-forget +
2686
+ // self-isolating so it can't mask the caller's error (e.g. import rollback).
2687
+ void this.vectorService?.deleteIndexStateSafe(assetId);
2688
+ }
2689
+ /** Re-point the owning coordinates of every asset owned by an entity, with
2690
+ * no blob movement (keys are immutable). Used by rename (ownerId),
2691
+ * changeType (ownerType), and cross-repo move/transfer (repo). No-op
2692
+ * without an index. */
2693
+ reassignOwnedAssets(fromType, fromId, changes) {
2694
+ this.assetIndex?.reassignOwner(this.repoName, fromType, fromId, changes);
813
2695
  }
814
2696
  /**
815
2697
  * List assets for an entity
@@ -820,6 +2702,47 @@ export class BaseGraphManager {
820
2702
  }
821
2703
  return await this.assetService.list(entityType, entityId, keyPrefix);
822
2704
  }
2705
+ /**
2706
+ * List every asset in the backend in one round-trip, tagged with
2707
+ * (entityType, entityId) origin. Workspace-wide since the asset backend
2708
+ * is shared across repos — callers filter by repo via {@link listEntityIds}.
2709
+ */
2710
+ async listAllAssets() {
2711
+ if (!this.assetService) {
2712
+ throw new Error('Asset service is not configured for this graph');
2713
+ }
2714
+ return await this.assetService.listAll();
2715
+ }
2716
+ /**
2717
+ * Enumerate (type, id) pairs in this repo. Used by endpoints (like
2718
+ * /api/repos/:repo/assets) that need to answer "does entity X belong to
2719
+ * this repo?".
2720
+ *
2721
+ * Backed by the file index, which reads frontmatter — so entries in
2722
+ * nested folders are included alongside legacy {plural-type}/ entries.
2723
+ */
2724
+ listEntityIds() {
2725
+ if (!existsSync(this.repoPath))
2726
+ return [];
2727
+ const results = [];
2728
+ for (const key of this.getFileIndex().keys()) {
2729
+ const slash = key.indexOf('/');
2730
+ if (slash <= 0)
2731
+ continue;
2732
+ results.push({ entityType: key.slice(0, slash), id: key.slice(slash + 1) });
2733
+ }
2734
+ return results;
2735
+ }
2736
+ /**
2737
+ * Resolve the absolute on-disk markdown path for an entity. Goes through
2738
+ * the file index so it works for both the flat repo-root layout and
2739
+ * arbitrary nested folders. Use this anywhere callers need a path —
2740
+ * never reconstruct one from `${plural-type}/${id}.md`, which assumes
2741
+ * the legacy typed-folder layout and silently misses everything else.
2742
+ */
2743
+ getEntityFilePath(entityType, entityId) {
2744
+ return this.findEntityFile(entityType, entityId);
2745
+ }
823
2746
  /**
824
2747
  * Delete an asset
825
2748
  */
@@ -839,66 +2762,104 @@ export class BaseGraphManager {
839
2762
  return await this.assetService.deleteAll(entityType, entityId, keyPrefix);
840
2763
  }
841
2764
  /**
842
- * Get dataset rows, optionally filtered
2765
+ * Resolve the authoritative CSV body for a dataset: the live editor body
2766
+ * when one exists (the grid may be ahead of disk), else the on-disk body.
2767
+ */
2768
+ datasetBody(entity, entityId) {
2769
+ return this.getLiveBody('dataset', entityId) ?? entity.document.content;
2770
+ }
2771
+ /**
2772
+ * Get dataset rows, optionally filtered. Columns come from the CSV header.
843
2773
  */
844
2774
  getDatasetRows(entityId, filter) {
845
2775
  const entity = this.get('dataset', entityId);
846
2776
  if (entity.data.entity_type !== 'dataset') {
847
2777
  throw new Error(`Entity ${entityId} is not a dataset`);
848
2778
  }
849
- const schema = entity.data.schema;
850
- const rows = this.csvService.parseRows(entity.document.content, schema);
2779
+ const rows = this.csvService.parseRows(this.datasetBody(entity, entityId));
851
2780
  if (filter && Object.keys(filter).length > 0) {
852
2781
  return this.csvService.filterRows(rows, filter);
853
2782
  }
854
2783
  return rows;
855
2784
  }
856
2785
  /**
857
- * Append rows to a dataset entity
2786
+ * Column names for a dataset, read from the CSV header. Independent of the
2787
+ * rows so callers get the real columns even for a header-only dataset or a
2788
+ * zero-match filter (where deriving columns from result rows would be empty).
2789
+ */
2790
+ getDatasetColumns(entityId) {
2791
+ const entity = this.get('dataset', entityId);
2792
+ if (entity.data.entity_type !== 'dataset') {
2793
+ throw new Error(`Entity ${entityId} is not a dataset`);
2794
+ }
2795
+ return this.csvService.columns(this.datasetBody(entity, entityId));
2796
+ }
2797
+ /**
2798
+ * Append rows to a dataset entity.
2799
+ *
2800
+ * The existing CSV body is preserved verbatim and the new rows are appended
2801
+ * after it — we do NOT re-parse and re-stringify existing rows. That keeps
2802
+ * datasets with duplicate column headers from collapsing (csv-parse's
2803
+ * object mode keeps only the last same-named column) and avoids reformatting
2804
+ * unrelated cells. New rows are mapped to the existing header columns; keys
2805
+ * not in the header are dropped (query the columns first). When the body has
2806
+ * no header yet, the columns are taken from the new rows.
2807
+ *
2808
+ * The write routes through `update()` so it shares the BodyWriteCoordinator
2809
+ * contract (per-doc lock, base-hash precondition, applyAgentContent, commit)
2810
+ * instead of writing to disk directly — same guarantees the agent's markdown
2811
+ * writes get. `baseRevision` makes a concurrent change since our read fail
2812
+ * loudly rather than silently back-stomp.
858
2813
  */
859
2814
  async appendDatasetRows(entityId, newRows, commitMessage) {
860
2815
  const entity = this.get('dataset', entityId);
861
2816
  if (entity.data.entity_type !== 'dataset') {
862
2817
  throw new Error(`Entity ${entityId} is not a dataset`);
863
2818
  }
864
- const schema = entity.data.schema;
865
- // Validate new rows against schema
866
- const validatedRows = newRows.map(row => {
867
- const stringRow = {};
2819
+ const base = this.datasetBody(entity, entityId);
2820
+ // Normalize incoming rows to string cells (agents may send numbers/bools).
2821
+ const normalizedRows = newRows.map(row => {
2822
+ const out = {};
868
2823
  for (const [k, v] of Object.entries(row)) {
869
- stringRow[k] = v !== null && v !== undefined ? String(v) : '';
2824
+ out[k] = v !== null && v !== undefined ? String(v) : '';
870
2825
  }
871
- return this.csvService.validateRow(stringRow, schema);
2826
+ return out;
872
2827
  });
873
- // Parse existing rows and append
874
- const existingRows = this.csvService.parseRows(entity.document.content, schema);
875
- const allRows = [...existingRows, ...validatedRows];
876
- // Stringify back to CSV
877
- const newContent = this.csvService.stringifyRows(allRows, schema);
878
- // Update the entity file (update content, bump updated_at)
879
- const entityPath = this.getEntityPath('dataset', entityId);
880
- const updatedData = {
881
- ...entity.data,
882
- updated_at: new Date().toISOString(),
883
- updated_by: this.gitUser.id,
884
- };
885
- this.markdownService.writeFile(entityPath, {
886
- frontmatter: updatedData,
2828
+ let newContent;
2829
+ const headerColumns = this.csvService.columns(base);
2830
+ if (headerColumns.length === 0) {
2831
+ // No header yet: derive columns from the new rows and write a fresh CSV.
2832
+ const columns = [];
2833
+ for (const row of normalizedRows) {
2834
+ for (const key of Object.keys(row)) {
2835
+ if (!columns.includes(key))
2836
+ columns.push(key);
2837
+ }
2838
+ }
2839
+ newContent = this.csvService.stringifyRows(normalizedRows, columns);
2840
+ }
2841
+ else {
2842
+ // Preserve the existing body byte-for-byte; append only the new rows.
2843
+ const appended = this.csvService.stringifyRows(normalizedRows, headerColumns, false);
2844
+ const baseTrimmed = base.replace(/\n+$/, '');
2845
+ newContent = appended ? `${baseTrimmed}\n${appended}` : `${baseTrimmed}\n`;
2846
+ }
2847
+ return this.update({
2848
+ entityType: 'dataset',
2849
+ entityId,
887
2850
  content: newContent,
888
- wikilinks: this.markdownService.extractWikilinks(newContent, updatedData),
2851
+ source: 'server-write',
2852
+ // Precondition is checked against DISK inside the coordinator's lock, so
2853
+ // baseRevision must be the DISK hash — not `base`, which is the LIVE body
2854
+ // (datasetBody prefers the live ytext). When the CSV editor has unsaved
2855
+ // edits the live body is ahead of disk; hashing it here would always
2856
+ // mismatch the disk check and throw StaleBaseRevisionError (masked today
2857
+ // by the live-typing gate, exposed once that gate is removed). The
2858
+ // appended `newContent` still derives from the live body, so live edits
2859
+ // are preserved; applyAgentContent then merges the rows into the editor.
2860
+ baseRevision: sha256OfBody(entity.document.content),
2861
+ commitMessage: commitMessage || `Append ${normalizedRows.length} row(s) to dataset: ${entityId}`,
889
2862
  });
890
- if (this.gitService) {
891
- const transaction = this.gitService.createTransaction();
892
- transaction.add({
893
- operation: 'update',
894
- entityType: 'dataset',
895
- entityId,
896
- filePath: entityPath,
897
- });
898
- const message = commitMessage || `Append ${validatedRows.length} row(s) to dataset: ${entityId}`;
899
- await transaction.commit(this.gitUser, message);
900
- }
901
- return this.get('dataset', entityId);
902
2863
  }
903
2864
  /**
904
2865
  * Get repository status