studiograph 1.3.48-next.248 → 1.3.48-next.249

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 (119) hide show
  1. package/dist/agent/tools/artifact-tools.d.ts +4 -0
  2. package/dist/agent/tools/artifact-tools.js.map +1 -1
  3. package/dist/agent/tools/folder-tools.d.ts +5 -2
  4. package/dist/agent/tools/folder-tools.js +83 -2
  5. package/dist/agent/tools/folder-tools.js.map +1 -1
  6. package/dist/core/types.d.ts +11 -0
  7. package/dist/core/types.js +20 -0
  8. package/dist/core/types.js.map +1 -1
  9. package/dist/core/workspace-manager.d.ts +20 -0
  10. package/dist/core/workspace-manager.js +60 -2
  11. package/dist/core/workspace-manager.js.map +1 -1
  12. package/dist/mcp/tools.d.ts +1 -1
  13. package/dist/mcp/tools.js +266 -160
  14. package/dist/mcp/tools.js.map +1 -1
  15. package/dist/server/routes/chat.js +67 -190
  16. package/dist/server/routes/chat.js.map +1 -1
  17. package/dist/server/routes/graph-api-access.d.ts +10 -4
  18. package/dist/server/routes/graph-api-access.js +8 -5
  19. package/dist/server/routes/graph-api-access.js.map +1 -1
  20. package/dist/server/routes/graph-api.js +79 -0
  21. package/dist/server/routes/graph-api.js.map +1 -1
  22. package/dist/server/routes/insights-api.js +4 -3
  23. package/dist/server/routes/insights-api.js.map +1 -1
  24. package/dist/services/artifact-store.d.ts +111 -0
  25. package/dist/services/artifact-store.js +235 -0
  26. package/dist/services/artifact-store.js.map +1 -0
  27. package/dist/services/assets/asset-index-service.d.ts +9 -0
  28. package/dist/services/assets/asset-index-service.js +11 -0
  29. package/dist/services/assets/asset-index-service.js.map +1 -1
  30. package/dist/services/workspace-access.d.ts +2 -0
  31. package/dist/web/_app/immutable/assets/SettingsDialog.DLCW88_K.css +1 -0
  32. package/dist/web/_app/immutable/chunks/{BxQgIVb7.js → 08ccpAwb.js} +2 -2
  33. package/dist/web/_app/immutable/chunks/{BNYeNIcr.js → 1A1PfCLT.js} +1 -1
  34. package/dist/web/_app/immutable/chunks/{DTub4iP1.js → 4M-H5IF4.js} +2 -2
  35. package/dist/web/_app/immutable/chunks/{BSFQWjQm.js → 55R1MOwv.js} +1 -1
  36. package/dist/web/_app/immutable/chunks/{LWxcXHte.js → 8NMKteKC.js} +1 -1
  37. package/dist/web/_app/immutable/chunks/{MSGI-9Cc.js → B0GtSrm7.js} +1 -1
  38. package/dist/web/_app/immutable/chunks/{DfCBxcND.js → B2TUq7Tx.js} +1 -1
  39. package/dist/web/_app/immutable/chunks/{Cf_u2Ors.js → B3uzhlw4.js} +2 -2
  40. package/dist/web/_app/immutable/chunks/{Dc2mTYyI.js → B4Ff89DM.js} +1 -1
  41. package/dist/web/_app/immutable/chunks/{BDOt0MKz2.js → B91IrHS82.js} +1 -1
  42. package/dist/web/_app/immutable/chunks/{Du2Y3UVf.js → B9od0BVp.js} +2 -2
  43. package/dist/web/_app/immutable/chunks/{Cko1LC1k.js → BFTCBlcp.js} +1 -1
  44. package/dist/web/_app/immutable/chunks/{rBu_hmtL2.js → BHbilOQf2.js} +1 -1
  45. package/dist/web/_app/immutable/chunks/{DwfZHSHP.js → BICS1Iik.js} +1 -1
  46. package/dist/web/_app/immutable/chunks/{Dah1D4RJ.js → BOsgZGlh.js} +1 -1
  47. package/dist/web/_app/immutable/chunks/{BbDf9TiJ.js → BZ-0H8m-.js} +1 -1
  48. package/dist/web/_app/immutable/chunks/B_QIVR6w.js +1 -0
  49. package/dist/web/_app/immutable/chunks/{DH-WyHBA.js → BdDRk883.js} +1 -1
  50. package/dist/web/_app/immutable/chunks/{DXQDje3_.js → Bdiorub0.js} +1 -1
  51. package/dist/web/_app/immutable/chunks/{UgJLJmun.js → Bents8FP.js} +1 -1
  52. package/dist/web/_app/immutable/chunks/{DMdcZaF3.js → BeoCSH68.js} +1 -1
  53. package/dist/web/_app/immutable/chunks/{CVEEIQ58.js → BfXeavEA.js} +1 -1
  54. package/dist/web/_app/immutable/chunks/{Djr4EgfL2.js → BjGnI0gr2.js} +1 -1
  55. package/dist/web/_app/immutable/chunks/{CwJMw0Pa.js → Bnt16ZW4.js} +2 -2
  56. package/dist/web/_app/immutable/chunks/{DTy-F7gP.js → BxCUaJ3N.js} +1 -1
  57. package/dist/web/_app/immutable/chunks/{B10Sb-A12.js → By3Z2hTQ2.js} +2 -2
  58. package/dist/web/_app/immutable/chunks/{BowHuz-T.js → C1ZfMHej.js} +1 -1
  59. package/dist/web/_app/immutable/chunks/{B0DfJ9g_2.js → C3pPex232.js} +1 -1
  60. package/dist/web/_app/immutable/chunks/{BdNVdD1A2.js → C8pVjgxL2.js} +1 -1
  61. package/dist/web/_app/immutable/chunks/{BckIHg4W2.js → CETcrIM92.js} +1 -1
  62. package/dist/web/_app/immutable/chunks/CIjrNgS6.js +11 -0
  63. package/dist/web/_app/immutable/chunks/{B7rkp-wA.js → CUdxuEbH.js} +1 -1
  64. package/dist/web/_app/immutable/chunks/{ZBVOB3SW.js → CYXSz9yb.js} +1 -1
  65. package/dist/web/_app/immutable/chunks/{C0z8Ad2G.js → CYxloZGb.js} +2 -2
  66. package/dist/web/_app/immutable/chunks/{BtRKhPh5.js → Cb1xGHhj.js} +1 -1
  67. package/dist/web/_app/immutable/chunks/{tkN39gp9.js → CeW2vAlc.js} +2 -2
  68. package/dist/web/_app/immutable/chunks/{W-sQF62y2.js → CorrHq532.js} +1 -1
  69. package/dist/web/_app/immutable/chunks/{v55oxZ8l.js → Crnl6Md_.js} +1 -1
  70. package/dist/web/_app/immutable/chunks/{BYzD0vP7.js → CvR2ceWq.js} +1 -1
  71. package/dist/web/_app/immutable/chunks/{BizPx2nv.js → D4hNQXDb.js} +1 -1
  72. package/dist/web/_app/immutable/chunks/{6iH0r6Nl.js → DI-vfMFw.js} +1 -1
  73. package/dist/web/_app/immutable/chunks/{BLxNxM-5.js → DMN_9bHq.js} +1 -1
  74. package/dist/web/_app/immutable/chunks/{jfm0DJ6s.js → DNf-Rqo5.js} +1 -1
  75. package/dist/web/_app/immutable/chunks/{DdGmozvg2.js → DTJ-iC7M2.js} +1 -1
  76. package/dist/web/_app/immutable/chunks/{DoJ7V80J2.js → DbW2ot-t2.js} +1 -1
  77. package/dist/web/_app/immutable/chunks/{BLQsxHKg.js → Dc91RPU4.js} +1 -1
  78. package/dist/web/_app/immutable/chunks/{CZLrcjI0.js → DrDn1B4Y.js} +3 -3
  79. package/dist/web/_app/immutable/chunks/{tEQ2Yl1H2.js → Dt7xLBBR2.js} +2 -2
  80. package/dist/web/_app/immutable/chunks/{BCwIJPci2.js → DukVcMiZ2.js} +1 -1
  81. package/dist/web/_app/immutable/chunks/{Cs11ZH9x2.js → EZDfis8_2.js} +1 -1
  82. package/dist/web/_app/immutable/chunks/{VCcJOGhl.js → I2KxFcoO.js} +1 -1
  83. package/dist/web/_app/immutable/chunks/{fSj1T3Iz.js → IR38yQ7g.js} +1 -1
  84. package/dist/web/_app/immutable/chunks/{-a871hs32.js → ScqH9xf82.js} +1 -1
  85. package/dist/web/_app/immutable/chunks/{NQSO2PAq.js → SfGK8Et-.js} +1 -1
  86. package/dist/web/_app/immutable/chunks/{J-14QkVJ.js → TQ8mmoY7.js} +1 -1
  87. package/dist/web/_app/immutable/chunks/{DH7vY5uU2.js → Y1vnnZ_M2.js} +1 -1
  88. package/dist/web/_app/immutable/chunks/{CF98DJyq2.js → efxT-S2T2.js} +1 -1
  89. package/dist/web/_app/immutable/chunks/{BL8rA7r82.js → ktBgiG5W2.js} +1 -1
  90. package/dist/web/_app/immutable/chunks/{BjBXJI7P.js → n0hPbJlk.js} +1 -1
  91. package/dist/web/_app/immutable/chunks/{DMWIs83C.js → qQ8b5slZ.js} +1 -1
  92. package/dist/web/_app/immutable/chunks/{0quobYVw2.js → tc5XRbdg2.js} +1 -1
  93. package/dist/web/_app/immutable/chunks/{Duw8Nwl1.js → z8SO2eB6.js} +2 -2
  94. package/dist/web/_app/immutable/entry/{app.xw45BMhX.js → app.BRo-5YLr.js} +2 -2
  95. package/dist/web/_app/immutable/entry/start.LPWH40A1.js +1 -0
  96. package/dist/web/_app/immutable/nodes/{0.DBgLDOcR.js → 0.Cz-OF0vW.js} +2 -2
  97. package/dist/web/_app/immutable/nodes/{1.B-BMuCT3.js → 1.1ArpZvPz.js} +1 -1
  98. package/dist/web/_app/immutable/nodes/{10.DEk6l-8a.js → 10.i2p1ywAL.js} +1 -1
  99. package/dist/web/_app/immutable/nodes/{11.JALOagtU.js → 11.Do63wBtK.js} +1 -1
  100. package/dist/web/_app/immutable/nodes/{12.BDlVwDIm.js → 12.Dux5L6Nc.js} +1 -1
  101. package/dist/web/_app/immutable/nodes/{13.CAYDF7h0.js → 13.Dk08N2ND.js} +1 -1
  102. package/dist/web/_app/immutable/nodes/{15.CojIp6Mm.js → 15.i29s6O-A.js} +1 -1
  103. package/dist/web/_app/immutable/nodes/{17.Dkuv0DbN.js → 17.DNWb99VO.js} +1 -1
  104. package/dist/web/_app/immutable/nodes/{18.ChIQzB8E.js → 18.DTyupqhr.js} +1 -1
  105. package/dist/web/_app/immutable/nodes/{2.Cb8RO_-f.js → 2.skcVmF1z.js} +4 -4
  106. package/dist/web/_app/immutable/nodes/{3.BnnVNK_n.js → 3.B5WBoJCl.js} +3 -3
  107. package/dist/web/_app/immutable/nodes/{4.D74o1QID.js → 4.C2jGw2l7.js} +3 -3
  108. package/dist/web/_app/immutable/nodes/{5.WcMBWdxt.js → 5.B48GFlrM.js} +1 -1
  109. package/dist/web/_app/immutable/nodes/{6.B084Jkrq.js → 6.m9OLVuUf.js} +3 -3
  110. package/dist/web/_app/immutable/nodes/{7.N-19azSR.js → 7.B_d0ei0n.js} +1 -1
  111. package/dist/web/_app/immutable/nodes/{8.tOur5mLJ.js → 8.DeVfsUW8.js} +1 -1
  112. package/dist/web/_app/immutable/nodes/{9.3Svt5uuJ.js → 9.Bp9VdZol.js} +1 -1
  113. package/dist/web/_app/version.json +1 -1
  114. package/dist/web/index.html +13 -13
  115. package/package.json +1 -1
  116. package/dist/web/_app/immutable/assets/SettingsDialog.Bdy6qXoG.css +0 -1
  117. package/dist/web/_app/immutable/chunks/DGS78XKn.js +0 -1
  118. package/dist/web/_app/immutable/chunks/Dt40mMXG.js +0 -8
  119. package/dist/web/_app/immutable/entry/start.X6Ea_iBL.js +0 -1
package/dist/mcp/tools.js CHANGED
@@ -16,7 +16,10 @@
16
16
  */
17
17
  import { z } from 'zod';
18
18
  import { ENTITY_SCHEMAS } from '../core/validation.js';
19
- import { getAccessibleRepos, hasRepoAccess, canWriteRepo, getAccessibleEntityIds, filterWikilinks, } from '../services/access-control.js';
19
+ import { isWorkspaceOwner } from '../services/auth-service.js';
20
+ import { getAccessibleRepos, hasRepoAccess, canWriteRepo, isFolderAdminOrOwner, getAccessibleEntityIds, filterWikilinks, } from '../services/access-control.js';
21
+ import { assetDeleteDenial } from '../services/asset-delete-authz.js';
22
+ import { isArchivedRepo } from '../core/types.js';
20
23
  import { createSharedFolder, FolderCreationError } from '../services/folder-creation.js';
21
24
  import { validateFolderPath } from '../core/folder-paths.js';
22
25
  import { existsSync } from 'fs';
@@ -25,8 +28,8 @@ import { isCuratedEntityType } from '../core/entity-types-catalog.js';
25
28
  import { describeZodObject } from '../core/schema-registry.js';
26
29
  import { getAgentAllowedEntityTypes } from '../agent/prompts/entity-types-section.js';
27
30
  import * as commentVirtualEntity from '../services/comment-virtual-entity.js';
28
- import { buildActor, prepareCreatePayload, prepareUpdatePayload, diagnosedUpdate, DroppedAssetError, assertEntityRefs, applyPatchEntity, bodyRevisionHash, } from '../services/entity-mutations.js';
29
- import { StaleBaseRevisionError } from '../core/graph.js';
31
+ import { buildActor, prepareCreatePayload, prepareUpdatePayload, diagnosedUpdate, DroppedAssetError, assertEntityRefs, applyPatchEntity, } from '../services/entity-mutations.js';
32
+ import { ArtifactStore, ArtifactWriteError } from '../services/artifact-store.js';
30
33
  import { hashArtifactBody } from '../agent/tools/artifact-tools.js';
31
34
  import { BridgeError, validateUserHandleInPayload, assertCanChangeUserHandle } from '../services/user-person-bridge.js';
32
35
  import { extractAssetEmbeds, stripFrontmatter, assetEmbed } from '../core/embeds.js';
@@ -153,48 +156,26 @@ const DEFAULT_FOLDER_BY_TYPE = {
153
156
  function relPathOf(absPath, repoRoot) {
154
157
  return absPath.startsWith(repoRoot + '/') ? absPath.slice(repoRoot.length + 1) : absPath;
155
158
  }
156
- function slugifyArtifactId(source) {
157
- const slug = (source || 'artifact')
158
- .normalize('NFKD')
159
- .replace(/[\u0300-\u036f]/g, '')
160
- .toLowerCase()
161
- .replace(/[^a-z0-9]+/g, '-')
162
- .replace(/^-+|-+$/g, '')
163
- .slice(0, 80);
164
- return slug || 'artifact';
165
- }
166
- /** Whether a bundle's entrypoint document is empty/whitespace (a blank canvas
167
- * stub, or a truncated tool-input stream). Mirrors the in-app rule in
168
- * chat.ts's persistArtifactFromChat — Phase 4's ArtifactStore unifies both. */
169
- function bundleEntrypointBlank(bundle) {
170
- const entry = bundle.files[bundle.entrypoint];
171
- return typeof entry !== 'string' || entry.trim().length === 0;
172
- }
173
- /** Whether a stored artifact body is a blank stub (valid bundle whose
174
- * entrypoint has no content). Parse failures count as NON-blank — never treat
175
- * unknown content as safely overwritable. */
176
- function artifactBodyIsBlank(body) {
177
- try {
178
- const validation = validateArtifactBundle(JSON.parse(body));
179
- if (!validation.ok || !validation.bundle)
180
- return false;
181
- return bundleEntrypointBlank(validation.bundle);
182
- }
183
- catch {
184
- return false;
159
+ /**
160
+ * Robustly resolve an entity from a search/list result so any hit re-opens
161
+ * without the caller guessing the physical type: explicit `path` wins; else try
162
+ * the supplied `entity_type`; on a miss (wrong/legacy type — e.g. an `event`
163
+ * whose `type` field is "meeting") fall back to id-only resolution. Shared by
164
+ * get_entity and delete_entity so the resolution can't drift between read and
165
+ * delete. Throws when nothing resolves (caller wraps via fail()).
166
+ */
167
+ function resolveEntityRef(graph, ref) {
168
+ if (ref.path)
169
+ return graph.getByPath(ref.path);
170
+ if (ref.entity_type) {
171
+ try {
172
+ return graph.get(ref.entity_type, ref.entity_id);
173
+ }
174
+ catch {
175
+ return graph.getById(ref.entity_id);
176
+ }
185
177
  }
186
- }
187
- function artifactFrontmatter(bundle, options = {}) {
188
- const data = {
189
- name: bundle.title,
190
- kind: bundle.kind,
191
- viewport_width: bundle.viewport.width,
192
- viewport_height: bundle.viewport.height,
193
- viewport_mode: bundle.viewport.mode,
194
- };
195
- if (options.includeStatus)
196
- data.status = 'draft';
197
- return data;
178
+ return graph.getById(ref.entity_id);
198
179
  }
199
180
  function parseArtifactBundleFromBody(content, label) {
200
181
  let rawBundle;
@@ -305,7 +286,10 @@ export function registerTools(server, wm, authService, ctx) {
305
286
  const mcpAllowedCreateTypes = getAgentAllowedEntityTypes(customWorkspaceTypes);
306
287
  const mcpAllowedSet = new Set(mcpAllowedCreateTypes);
307
288
  // ── list_repos ────────────────────────────────────────────────────────────
308
- reg('list_repos', { description: 'List folders in the Studiograph workspace that you have access to' }, async () => ok(getAccessibleRepos(user, wm, authService)));
289
+ reg('list_repos', { description: 'List folders in the Studiograph workspace that you have access to' },
290
+ // Archived folders are hidden from the inventory (parity with the sidebar +
291
+ // agent folder list); they stay reachable by name for unarchive.
292
+ async () => ok(getAccessibleRepos(user, wm, authService).filter(r => !isArchivedRepo(r))));
309
293
  // ── create_repo ───────────────────────────────────────────────────────────
310
294
  //
311
295
  // Creates a new shared folder in the workspace and grants the
@@ -419,24 +403,8 @@ export function registerTools(server, wm, authService, ctx) {
419
403
  }
420
404
  const graph = wm.getGraph(repo);
421
405
  // Resolve robustly so any search/list result re-opens without the
422
- // caller guessing the physical type: explicit path wins; else try the
423
- // supplied type; on a miss (wrong/legacy type e.g. an `event` whose
424
- // `type` field is "meeting") fall back to id-only resolution.
425
- let entity;
426
- if (path) {
427
- entity = graph.getByPath(path);
428
- }
429
- else if (entity_type) {
430
- try {
431
- entity = graph.get(entity_type, entity_id);
432
- }
433
- catch {
434
- entity = graph.getById(entity_id);
435
- }
436
- }
437
- else {
438
- entity = graph.getById(entity_id);
439
- }
406
+ // caller guessing the physical type (shared with delete_entity).
407
+ const entity = resolveEntityRef(graph, { entity_type, entity_id, path });
440
408
  // Prefer the live editor body when a seeded Y.Doc is open: graph reads
441
409
  // disk, which lags the editor by the autosave debounce, so a user's
442
410
  // freshly-placed image/asset embed may not be on disk yet. Use the
@@ -828,117 +796,61 @@ export function registerTools(server, wm, authService, ctx) {
828
796
  if (base_hash && !entity_id) {
829
797
  return fail(new Error('base_hash implies updating an existing artifact, but entity_id is missing — pass the entity_id you read with get_entity, or omit base_hash to create a new artifact.'));
830
798
  }
831
- const validation = validateArtifactBundle(rawBundle);
832
- if (!validation.ok || !validation.bundle) {
833
- return fail(new Error(`Invalid artifact bundle: ${validation.errors.join('; ')}`));
834
- }
835
- const bundle = validation.bundle;
836
799
  const graph = wm.getGraph(repo);
837
800
  const actor = buildActor(user);
838
- const body = `${JSON.stringify(bundle, null, 2)}\n`;
839
- const createData = artifactFrontmatter(bundle, { includeStatus: true });
840
- const updateData = artifactFrontmatter(bundle);
841
801
  return withAgentActivity(repo, entity_id ? 'editing' : 'creating', async () => {
842
802
  try {
843
- const preferredId = entity_id || slugifyArtifactId(bundle.artifactId || bundle.title);
844
- let resolvedId = preferredId;
845
- let existing = null;
846
- if (entity_id) {
847
- try {
848
- existing = graph.get('artifact', preferredId);
849
- }
850
- catch {
851
- existing = null;
852
- }
853
- }
854
- else {
855
- let suffix = 2;
856
- while (suffix < 1000) {
857
- try {
858
- graph.get('artifact', resolvedId);
859
- resolvedId = `${preferredId}-${suffix++}`;
860
- }
861
- catch {
862
- break;
863
- }
864
- }
865
- }
866
- // The caller asserted "this is an edit of the version I read"
867
- // (base_hash) but the target no longer resolves — deleted, moved, or
868
- // renamed since the read. Recreating it from a possibly stale copy
869
- // would silently resurrect deleted content; fail loudly instead.
870
- if (!existing && entity_id && base_hash) {
871
- return fail(new Error(`Artifact "${entity_id}" no longer exists in "${repo}" (deleted or moved since you read it). Re-check with get_entity or search_entities before retrying — refusing to recreate it from a possibly stale copy.`));
872
- }
873
- if (existing) {
874
- const liveBody = graph.getLiveBody('artifact', resolvedId) ?? existing.document.content;
875
- const targetIsBlank = artifactBodyIsBlank(liveBody);
876
- // Agent-protocol staleness rules (mirror the in-app create_artifact
877
- // baseHash rules; Phase 4's ArtifactStore unifies both surfaces):
878
- // 1. Updating an artifact that already has content REQUIRES the
879
- // artifact_hash from get_entity — an edit not derived from a
880
- // read would silently drop someone else's changes.
881
- if (!targetIsBlank && !base_hash) {
882
- return fail(new Error('This artifact already has content — call get_entity first and pass its artifact_hash as base_hash, so a concurrent change is detected instead of silently overwritten. (Intentionally clearing it requires base_hash plus replace: true.)'));
883
- }
884
- // 2. The hash must match the LIVE body (the same source get_entity
885
- // hashed) — otherwise the edit was derived from an outdated read.
886
- if (base_hash && base_hash !== hashArtifactBody(liveBody)) {
887
- return fail(new Error('Artifact changed since you read it (base_hash mismatch). Call get_entity again, re-apply your change to the current source, then retry with the new artifact_hash.'));
888
- }
889
- // 3. A blank bundle over a populated artifact is almost always a
890
- // truncated/failed generation, not an intentional clear.
891
- if (!targetIsBlank && bundleEntrypointBlank(bundle) && !replace) {
892
- return fail(new Error('The new bundle is blank but this artifact has content. Pass replace: true only if you intend to clear it.'));
893
- }
894
- // Write-time optimistic concurrency (mirrors the in-app rule): the
895
- // coordinator verifies the DISK body still matches what we read
896
- // here, so two racing whole-bundle writes can't both land — the
897
- // loser gets a conflict instead of silently clobbering.
898
- const options = prepareUpdatePayload({
899
- entityType: 'artifact',
900
- entityId: resolvedId,
901
- data: updateData,
902
- content: body,
903
- baseRevision: bodyRevisionHash(existing.document.content),
904
- }, actor);
905
- const { entity, frontmatter_changed, content_changed, noop, broadcast } = await diagnosedUpdate(graph, options, { data: updateData, content: body });
906
- emitEntityChange('updated', repo, entity.entityType, entity.id);
907
- return ok({
908
- success: true,
909
- action: 'updated',
910
- id: entity.id,
911
- type: entity.entityType,
912
- repo,
913
- path: relPathOf(entity.path, graph.getRepoPath()),
914
- commit: graph.getGitService()?.getHeadCommit() ?? null,
915
- frontmatter_changed,
916
- content_changed,
917
- noop,
918
- broadcast,
919
- });
920
- }
921
- const entity = await graph.create(prepareCreatePayload({
922
- entityType: 'artifact',
923
- entityId: resolvedId,
924
- data: createData,
925
- content: body,
803
+ // The ArtifactStore owns the whole write lifecycle (validate, identity,
804
+ // blank-stub adoption, content-equality idempotency, the OCC rules, the
805
+ // diagnosed write). The MCP surface stays thin: access (above), the
806
+ // response envelope, and the change broadcast.
807
+ const result = await new ArtifactStore(graph).write({
808
+ bundle: rawBundle,
809
+ actor,
810
+ // MCP entity_id is create-or-update-AT-id: a caller picks the id and
811
+ // expects it created if absent (unless base_hash asserts an edit).
812
+ target: entity_id ? { entityId: entity_id, createIfMissing: true } : null,
813
+ baseHash: base_hash,
814
+ replace,
926
815
  folderPath: folder_path || undefined,
927
- }, actor));
928
- emitEntityChange('created', repo, entity.entityType, entity.id);
816
+ });
817
+ const entity = result.entity;
818
+ // Broadcast only when something actually changed. `noop` covers BOTH the
819
+ // content-equality short-circuit (no write) AND a diagnosedUpdate that
820
+ // ran but changed nothing — neither should emit a phantom change. Matches
821
+ // the chat surface's `!location.noop` gate.
822
+ if (!result.noop)
823
+ emitEntityChange(result.action, repo, entity.entityType, entity.id);
929
824
  return ok({
930
825
  success: true,
931
- action: 'created',
932
- id: entity.id,
826
+ action: result.action,
827
+ id: result.entityId,
933
828
  type: entity.entityType,
934
829
  repo,
935
830
  path: relPathOf(entity.path, graph.getRepoPath()),
936
831
  commit: graph.getGitService()?.getHeadCommit() ?? null,
832
+ noop: result.noop,
833
+ ...(result.diagnostics ? {
834
+ frontmatter_changed: result.diagnostics.frontmatterChanged,
835
+ content_changed: result.diagnostics.contentChanged,
836
+ broadcast: result.diagnostics.broadcast,
837
+ } : {}),
937
838
  });
938
839
  }
939
840
  catch (err) {
940
- if (err instanceof StaleBaseRevisionError) {
941
- return fail(new Error('Another write landed on this artifact while yours was in flight. Call get_entity to re-read the current source, re-apply your change, then retry passing its artifact_hash as base_hash.'));
841
+ if (err instanceof ArtifactWriteError) {
842
+ switch (err.code) {
843
+ case 'needs-base-hash':
844
+ return fail(new Error('This artifact already has content — call get_entity first and pass its artifact_hash as base_hash, so a concurrent change is detected instead of silently overwritten. (Intentionally clearing it requires base_hash plus replace: true.)'));
845
+ case 'stale':
846
+ return fail(new Error('Artifact changed since you read it (base_hash mismatch). Call get_entity again, re-apply your change to the current source, then retry with the new artifact_hash.'));
847
+ case 'blank-overwrite':
848
+ return fail(new Error('The new bundle is blank but this artifact has content. Pass replace: true only if you intend to clear it.'));
849
+ case 'deleted':
850
+ return fail(new Error(`Artifact "${entity_id}" no longer exists in "${repo}" (deleted or moved since you read it). Re-check with get_entity or search_entities before retrying — refusing to recreate it from a possibly stale copy.`));
851
+ default:
852
+ return fail(new Error(err.message));
853
+ }
942
854
  }
943
855
  return fail(err);
944
856
  }
@@ -1681,6 +1593,200 @@ export function registerTools(server, wm, authService, ctx) {
1681
1593
  }
1682
1594
  });
1683
1595
  });
1596
+ // ── delete_entity ─────────────────────────────────────────────────────────
1597
+ //
1598
+ // Delete an entry. Git-committed and recoverable from history
1599
+ // (list_recent_deletions / restore_file). Embedded assets are KEPT — assets
1600
+ // are folder-owned library citizens that entries only reference
1601
+ // (assets-referenced-not-owned P1), so deleting an entry never destroys
1602
+ // binaries. In the workspace_wide Assets store the per-asset uploader/owner
1603
+ // delete gate still applies (D2). Robust resolution mirrors get_entity.
1604
+ const deleteEntitySchema = z.object({
1605
+ repo: z.string().describe('Folder name'),
1606
+ entity_type: z.string().optional().describe('Canonical entity type (as returned by search/list `type`). Optional: when omitted or wrong, the entry is resolved by id.'),
1607
+ entity_id: z.string().optional().describe('Entity ID (kebab-case slug). Provide entity_id or path.'),
1608
+ path: z.string().optional().describe('Repo-relative file path from a search/list result. When given, resolves directly and ignores entity_type.'),
1609
+ });
1610
+ reg('delete_entity', {
1611
+ description: 'Delete an entry. Git-committed and recoverable from history via list_recent_deletions / restore_file. ' +
1612
+ 'Embedded assets are NOT deleted — assets are folder-owned and only referenced by entries. ' +
1613
+ 'Robust resolution: pass type+id or path exactly as returned by search_entities/list_entities; a wrong or omitted type still resolves by id. ' +
1614
+ 'Destructive — only call on explicit user request.',
1615
+ inputSchema: deleteEntitySchema,
1616
+ }, async (args) => {
1617
+ const { repo, entity_type, entity_id, path } = deleteEntitySchema.parse(args);
1618
+ if (!hasRepoAccess(user, repo, wm, authService))
1619
+ return notFound(repo);
1620
+ if (!canWriteRepo(user, repo, wm, authService)) {
1621
+ return fail(new Error(`You do not have write access to folder "${repo}"`));
1622
+ }
1623
+ if (!entity_id && !path)
1624
+ return fail(new Error('delete_entity requires entity_id or path'));
1625
+ const actor = buildActor(user);
1626
+ return withAgentActivity(repo, 'deleting', async () => {
1627
+ try {
1628
+ const graph = wm.getGraph(repo);
1629
+ // Resolve robustly (shared with get_entity): explicit path wins; else
1630
+ // try the supplied type; on a miss fall back to id-only resolution.
1631
+ const entity = resolveEntityRef(graph, { entity_type, entity_id, path });
1632
+ const resolvedType = entity.entityType;
1633
+ const resolvedId = entity.id;
1634
+ // Workspace-Assets store gate (D2): in a workspace_wide folder only the
1635
+ // uploader or a workspace owner may delete — folder-write is not enough.
1636
+ const denial = assetDeleteDenial(wm.getAssetIndex(), wm.getRepoConfig(repo), user, repo, resolvedType, resolvedId);
1637
+ if (denial)
1638
+ return fail(new Error(denial));
1639
+ // Soft bridge-awareness: deleting a person linked to an auth account
1640
+ // orphans that user. Don't block — surface a warning so the agent can
1641
+ // tell the user (the Profile panel catches the orphan after).
1642
+ let bridgeWarning;
1643
+ if (resolvedType === 'person') {
1644
+ const handle = entity.data?.user_handle;
1645
+ if (typeof handle === 'string' && handle.length > 0) {
1646
+ bridgeWarning = `This record was linked to the "${handle}" account. Deleting it unlinks that user — their "My Tasks", @mentions, and assignments stop resolving until they link a new record.`;
1647
+ }
1648
+ }
1649
+ await graph.delete(resolvedType, resolvedId, undefined, actor);
1650
+ emitEntityChange('deleted', repo, resolvedType, resolvedId);
1651
+ return ok({
1652
+ success: true,
1653
+ repo,
1654
+ type: resolvedType,
1655
+ id: resolvedId,
1656
+ ...(bridgeWarning ? { warning: bridgeWarning } : {}),
1657
+ });
1658
+ }
1659
+ catch (err) {
1660
+ return fail(err);
1661
+ }
1662
+ });
1663
+ });
1664
+ // ── delete_subfolder ──────────────────────────────────────────────────────
1665
+ //
1666
+ // Delete a subfolder inside a folder/collection. Empty → delete; non-empty +
1667
+ // recursive:false → refuse with a blast-radius payload; recursive:true →
1668
+ // delete the folder + its entries. Deleted entries are git-recoverable; their
1669
+ // referenced assets are kept (folder-owned). Distinct from archive_repo, which
1670
+ // reversibly hides an entire top-level folder.
1671
+ const deleteSubfolderSchema = z.object({
1672
+ repo: z.string().describe('Folder name (collection) containing the subfolder'),
1673
+ path: z.string().describe('Repo-relative POSIX path of the subfolder to delete (e.g. "Clients/Acme")'),
1674
+ recursive: z.boolean().optional().describe('Delete the folder AND every entry/subfolder inside it. Defaults to false: a non-empty folder is REFUSED with a blast-radius payload (entriesDeleted, subfoldersDeleted) so you can confirm with the user first. NEVER pass true on the first attempt.'),
1675
+ });
1676
+ reg('delete_subfolder', {
1677
+ description: 'Delete a subfolder inside a folder. Empty subfolders delete immediately. A non-empty subfolder is REFUSED unless recursive:true — the refusal returns the blast radius (entries + subfolders that would be removed) so you can confirm with the user before re-invoking. Deleted ENTRIES are git-recoverable; their referenced assets are kept. To remove a whole top-level folder, prefer archive_repo (reversible) — permanent folder deletion is web/human-only.',
1678
+ inputSchema: deleteSubfolderSchema,
1679
+ }, async (args) => {
1680
+ const { repo, path, recursive } = deleteSubfolderSchema.parse(args);
1681
+ if (!hasRepoAccess(user, repo, wm, authService))
1682
+ return notFound(repo);
1683
+ if (!canWriteRepo(user, repo, wm, authService)) {
1684
+ return fail(new Error(`You do not have write access to folder "${repo}"`));
1685
+ }
1686
+ // Workspace-Assets store gate (mirror the web route): a recursive delete in
1687
+ // a workspace_wide store destroys every asset under the folder, bypassing
1688
+ // the per-asset uploader/owner rule — restrict it to a workspace owner.
1689
+ if (recursive === true && wm.getRepoConfig(repo)?.workspace_wide && !isWorkspaceOwner(user.role)) {
1690
+ return fail(new Error('Only a workspace owner can recursively delete folders in the shared Assets store.'));
1691
+ }
1692
+ const actor = buildActor(user);
1693
+ return withAgentActivity(repo, 'deleting', async () => {
1694
+ try {
1695
+ // Validate + normalize the path BEFORE the destructive call (don't
1696
+ // rely on deleteFolder's internal check as the only guard against
1697
+ // traversal/root/malformed paths). validateFolderPath throws
1698
+ // InvalidFolderPathError for traversal and returns '' for the repo
1699
+ // root, which deleteFolder also rejects. The throw lands in the catch
1700
+ // below and surfaces as a fail() result. Pass the normalized path.
1701
+ const normPath = validateFolderPath(path);
1702
+ const graph = wm.getGraph(repo);
1703
+ const result = await graph.deleteFolder(normPath, { recursive: recursive === true, user: actor });
1704
+ emitEntityChange('deleted', repo, '_folder', normPath);
1705
+ return ok({
1706
+ success: true,
1707
+ repo,
1708
+ path: normPath,
1709
+ entriesDeleted: result.entriesDeleted,
1710
+ subfoldersDeleted: result.subfoldersDeleted,
1711
+ });
1712
+ }
1713
+ catch (err) {
1714
+ // Surface the blast radius so the caller can confirm before retrying
1715
+ // recursively (structured, like the web route's 409 body).
1716
+ if (err?.code === 'FOLDER_NOT_EMPTY' && err.blastRadius) {
1717
+ return {
1718
+ content: [{
1719
+ type: 'text',
1720
+ text: JSON.stringify({
1721
+ error: err.message,
1722
+ code: 'FOLDER_NOT_EMPTY',
1723
+ blast_radius: err.blastRadius,
1724
+ }, null, 2),
1725
+ }],
1726
+ isError: true,
1727
+ };
1728
+ }
1729
+ return fail(err);
1730
+ }
1731
+ });
1732
+ });
1733
+ // ── archive_repo / unarchive_repo ─────────────────────────────────────────
1734
+ //
1735
+ // Archive is a reversible, lossless, identity-preserving repo-level hide (a
1736
+ // flag on the config, NOT a rename). An archived folder drops out of the
1737
+ // sidebar, search, graph, and the agent inventory but keeps its entries,
1738
+ // wikilinks, and assets. Agents/MCP may archive but may NEVER permanently
1739
+ // delete a repo — permanent deletion stays a deliberate human action in the
1740
+ // web UI. Folder admin or workspace owner only.
1741
+ const archiveRepoSchema = z.object({
1742
+ repo: z.string().describe('Folder name to archive'),
1743
+ });
1744
+ reg('archive_repo', {
1745
+ description: 'Archive a folder — a reversible, lossless hide. The folder drops out of the sidebar, search, graph, and the agent folder inventory, but nothing is deleted (entries, wikilinks, and assets are preserved) and it can be restored with unarchive_repo. Use this instead of deleting a folder: permanent folder deletion is a human-only action in the web UI and is never available to agents. Requires folder admin or workspace owner.',
1746
+ inputSchema: archiveRepoSchema,
1747
+ }, async (args) => {
1748
+ const { repo } = archiveRepoSchema.parse(args);
1749
+ if (!hasRepoAccess(user, repo, wm, authService))
1750
+ return notFound(repo);
1751
+ if (!isFolderAdminOrOwner(user, repo, wm, authService)) {
1752
+ return fail(new Error(`Archiving folder "${repo}" requires folder admin or workspace owner.`));
1753
+ }
1754
+ try {
1755
+ const updated = await wm.setRepoArchived(repo, true, user.handle);
1756
+ return ok({
1757
+ success: true,
1758
+ repo,
1759
+ archived_at: updated.archived_at ?? null,
1760
+ message: `Archived folder "${repo}". It's hidden from the workspace but fully recoverable — call unarchive_repo to restore it.`,
1761
+ });
1762
+ }
1763
+ catch (err) {
1764
+ return fail(err);
1765
+ }
1766
+ });
1767
+ reg('unarchive_repo', {
1768
+ description: 'Restore a previously archived folder so it reappears in the sidebar, search, graph, and the agent folder inventory. Requires folder admin or workspace owner.',
1769
+ inputSchema: archiveRepoSchema,
1770
+ }, async (args) => {
1771
+ const { repo } = archiveRepoSchema.parse(args);
1772
+ if (!hasRepoAccess(user, repo, wm, authService))
1773
+ return notFound(repo);
1774
+ if (!isFolderAdminOrOwner(user, repo, wm, authService)) {
1775
+ return fail(new Error(`Unarchiving folder "${repo}" requires folder admin or workspace owner.`));
1776
+ }
1777
+ try {
1778
+ const updated = await wm.setRepoArchived(repo, false, user.handle);
1779
+ return ok({
1780
+ success: true,
1781
+ repo,
1782
+ archived: isArchivedRepo(updated),
1783
+ message: `Restored folder "${repo}".`,
1784
+ });
1785
+ }
1786
+ catch (err) {
1787
+ return fail(err);
1788
+ }
1789
+ });
1684
1790
  // ── validate_wikilinks ────────────────────────────────────────────────────
1685
1791
  const validateWikilinksSchema = z.object({
1686
1792
  repo: z.string().describe('Folder name'),