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