@mnstry/atelier 0.2.0-alpha.5 → 0.2.0-alpha.8
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/CHANGELOG.md +191 -0
- package/README.md +86 -25
- package/SECURITY.md +6 -1
- package/contracts/atelier-adoption-policy.v1.schema.json +49 -0
- package/contracts/atelier-coauthor.v1.schema.json +163 -0
- package/contracts/atelier-decision-request.v1.schema.json +476 -0
- package/contracts/atelier-decision-result.v1.schema.json +500 -0
- package/contracts/atelier-guide.v1.schema.json +227 -0
- package/contracts/atelier-intake.v1.schema.json +151 -0
- package/contracts/atelier-migration.v2.schema.json +61 -0
- package/contracts/atelier-obsidian-acceptance-receipt.v1.schema.json +211 -0
- package/contracts/atelier-obsidian-apply-policy.v1.schema.json +298 -0
- package/contracts/atelier-obsidian-corpus-profile.v1.schema.json +98 -0
- package/contracts/atelier-obsidian-edit-operation.v1.schema.json +138 -0
- package/contracts/atelier-obsidian-ext-settings.v1.schema.json +287 -0
- package/contracts/atelier-obsidian-generation-manifest.v1.schema.json +353 -0
- package/contracts/atelier-obsidian-proposal-receipt.v1.schema.json +110 -0
- package/contracts/atelier-obsidian-publication-journal.v1.schema.json +167 -0
- package/contracts/atelier-obsidian-scope.v1.schema.json +264 -0
- package/contracts/atelier-obsidian-service-state.v1.schema.json +149 -0
- package/contracts/atelier-obsidian-source-snapshot.v1.schema.json +179 -0
- package/contracts/atelier-pane-presentation.v1.schema.json +76 -0
- package/contracts/atelier-presentation.v1.schema.json +1293 -0
- package/contracts/atelier-repository-observation.v1.schema.json +163 -0
- package/contracts/atelier-review.v1.schema.json +1601 -0
- package/contracts/atelier-skill-steward.v1.schema.json +692 -0
- package/contracts/atelier-upgrade-plan.v2.schema.json +398 -0
- package/contracts/atelier-upgrade-receipt.v1.schema.json +99 -0
- package/contracts/atelier-vault-publication.v1.schema.json +58 -0
- package/docs/assurance-controls.md +12 -2
- package/docs/atelier-runtime.md +13 -2
- package/docs/atelier-sync.md +172 -0
- package/docs/blocks/claims.md +28 -18
- package/docs/blocks/will-not-do.md +12 -3
- package/docs/coauthor-session.md +57 -0
- package/docs/connected-composition.md +75 -0
- package/docs/decisions.md +167 -0
- package/docs/exact-upgrades.md +159 -0
- package/docs/extension-pack-lifecycle.md +33 -0
- package/docs/guided-upgrades.md +99 -0
- package/docs/install.md +65 -9
- package/docs/intake-and-guides.md +65 -0
- package/docs/integration-and-review-plan.md +409 -0
- package/docs/integration-contract-decisions.md +22 -0
- package/docs/local-review.md +84 -0
- package/docs/local-services.md +227 -0
- package/docs/local-state.md +107 -0
- package/docs/obsidian-contract.md +687 -0
- package/docs/obsidian.md +387 -0
- package/docs/portable-presentation.md +328 -0
- package/docs/presentation-consumer-boundaries.md +75 -0
- package/docs/presentation-review-disposition.md +47 -0
- package/docs/project-command-inventory.md +38 -0
- package/docs/project-options.md +53 -0
- package/docs/release-engineering.md +99 -2
- package/docs/review-portability.md +55 -0
- package/docs/skill-steward.md +156 -0
- package/docs/upgrade.md +70 -2
- package/docs/vault-service.md +292 -0
- package/fixtures/atelier-coauthor/invalid/authority.v1.json +5 -0
- package/fixtures/atelier-coauthor/valid/config.v1.json +12 -0
- package/fixtures/atelier-guide/invalid/authority.v1.json +11 -0
- package/fixtures/atelier-guide/valid/capability.v1.json +10 -0
- package/fixtures/atelier-guide/valid/consent.v1.json +11 -0
- package/fixtures/atelier-guide/valid/engagement.v1.json +7 -0
- package/fixtures/atelier-guide/valid/offer.v1.json +10 -0
- package/fixtures/atelier-intake/invalid/authority.v1.json +9 -0
- package/fixtures/atelier-intake/valid/attempt.v1.json +8 -0
- package/fixtures/atelier-intake/valid/completion.v1.json +9 -0
- package/fixtures/atelier-intake/valid/source.v1.json +7 -0
- package/fixtures/atelier-repository-observation/invalid/complete-with-blocker.v1.json +18 -0
- package/fixtures/atelier-repository-observation/valid/complete-local.v1.json +48 -0
- package/fixtures/atelier-review/invalid/authenticated-identity.v1.json +22 -0
- package/fixtures/atelier-review/valid/bound.v1.json +229 -0
- package/fixtures/atelier-review/valid/bundle.v1.json +267 -0
- package/fixtures/atelier-review/valid/contribution.v1.json +22 -0
- package/fixtures/atelier-review/valid/lifecycle.v1.json +14 -0
- package/fixtures/atelier-skill-steward/audit/invalid/source-mutation.v1.json +26 -0
- package/fixtures/atelier-skill-steward/audit/valid/clean.v1.json +26 -0
- package/fixtures/atelier-skill-steward/candidates/invalid/workflow-key.v1.json +37 -0
- package/fixtures/atelier-skill-steward/candidates/valid/create.v1.json +37 -0
- package/fixtures/atelier-skill-steward/lock/invalid/bad-digest.v1.json +18 -0
- package/fixtures/atelier-skill-steward/lock/valid/installed.v1.json +23 -0
- package/fixtures/atelier-skill-steward/plan/invalid/delete-action.v1.json +33 -0
- package/fixtures/atelier-skill-steward/plan/valid/add.v1.json +33 -0
- package/fixtures/atelier-upgrade-transaction/adoption-policy/invalid/example.json +12 -0
- package/fixtures/atelier-upgrade-transaction/adoption-policy/valid/example.json +11 -0
- package/fixtures/atelier-upgrade-transaction/migration/invalid/example.json +20 -0
- package/fixtures/atelier-upgrade-transaction/migration/valid/example.json +19 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-plan/invalid/example.json +58 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-plan/valid/example.json +57 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-receipt/invalid/example.json +15 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-receipt/valid/example.json +14 -0
- package/fixtures/atelier-vault-publication/invalid/publication.json +5 -0
- package/fixtures/atelier-vault-publication/valid/publication.json +11 -0
- package/fixtures/decisions/request/invalid/unknown-authority.v1.json +61 -0
- package/fixtures/decisions/request/valid/triage.v1.json +60 -0
- package/fixtures/decisions/result/invalid/execution-authority.v1.json +50 -0
- package/fixtures/decisions/result/valid/abstained.v1.json +23 -0
- package/fixtures/decisions/result/valid/assessed.v1.json +50 -0
- package/fixtures/obsidian/acceptance/ap05-extra-notes.json +14 -0
- package/fixtures/obsidian/acceptance/receipts/G07.valid.v1.json +63 -0
- package/fixtures/obsidian/acceptance/receipts/G13.valid.v1.json +69 -0
- package/fixtures/obsidian/acceptance/receipts/G14.valid.v1.json +62 -0
- package/fixtures/obsidian/acceptance/receipts/G15.valid.v1.json +56 -0
- package/fixtures/obsidian/acceptance/receipts/G16.valid.v1.json +73 -0
- package/fixtures/obsidian/acceptance/receipts/G17.valid.v1.json +75 -0
- package/fixtures/obsidian/acceptance/receipts/G18.valid.v1.json +64 -0
- package/fixtures/obsidian/acceptance/service-sentinels.json +10 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/duplicate-evidence-name.v1.json +39 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/evidence-by-absolute-path.v1.json +34 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/missing-candidate-tree.v1.json +33 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/no-evidence.v1.json +28 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-outcome.v1.json +34 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-top-level-field.v1.json +35 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/valid/node-gate.v1.json +34 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/automatic-without-edit-class.v1.json +27 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/conflict-overwrite.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/missing-policy-digest.v1.json +28 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unbounded-batch.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unimplemented-edit-class.v1.json +30 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-mode.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-selector-operator.v1.json +22 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-top-level-field.v1.json +30 -0
- package/fixtures/obsidian/contracts/apply-policy/valid/automatic-scoped.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/valid/manual-default.v1.json +20 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-path-in-ext.v1.json +30 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-repository-root.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/duplicate-repository-id.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/overlapping-managed-roots.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-enrollment.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-top-level-field.v1.json +28 -0
- package/fixtures/obsidian/contracts/corpus-profile/valid/three-repositories.v1.json +27 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/absolute-recovery-ref.v1.json +21 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/inline-observed-bytes.v1.json +22 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/missing-base-digest.v1.json +20 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/short-idempotency-key.v1.json +21 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/unknown-kind.v1.json +21 -0
- package/fixtures/obsidian/contracts/edit-operation/valid/pending-body-replacement.v1.json +21 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/absolute-path-prefix.v1.json +28 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/apply-policy-in-portable-settings.v1.json +30 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/duplicate-scope-id.v1.json +27 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/machine-local-vault-path.v1.json +28 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/service-port-in-portable-settings.v1.json +28 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/unknown-default-scope.v1.json +27 -0
- package/fixtures/obsidian/contracts/ext-settings/valid/disabled.v1.json +5 -0
- package/fixtures/obsidian/contracts/ext-settings/valid/enabled-with-scopes.v1.json +27 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/absolute-note-path.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/complete-with-unwritten-notes.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/derived-link-claimed-declared.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/duplicate-note-identity.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/in-scope-endpoint-missing.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/title-only-note-path.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-note-field.v1.json +115 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-relation-type.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/withheld-endpoint.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/valid/two-notes.v1.json +114 -0
- package/fixtures/obsidian/contracts/oracles/scope-cases.json +412 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/accepted-without-proposal-id.v1.json +12 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/missing-adapter-operation-id.v1.json +11 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-backpressure-outcome.v1.json +12 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-top-level-field.v1.json +13 -0
- package/fixtures/obsidian/contracts/proposal-receipt/valid/accepted-new.v1.json +12 -0
- package/fixtures/obsidian/contracts/proposal-receipt/valid/deferred.v1.json +12 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/absolute-recovery-ref.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/duplicate-sequence.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/empty-protocol-id.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/missing-protocol-id.v1.json +46 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-entry-field.v1.json +48 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-state.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/valid/committed.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/valid/updating-after-restart.v1.json +22 -0
- package/fixtures/obsidian/contracts/scope/invalid/absolute-path-prefix.v1.json +15 -0
- package/fixtures/obsidian/contracts/scope/invalid/duplicate-ids.v1.json +18 -0
- package/fixtures/obsidian/contracts/scope/invalid/expansion-without-node-budget.v1.json +40 -0
- package/fixtures/obsidian/contracts/scope/invalid/full-mode-with-subset.v1.json +8 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-mode.v1.json +41 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-key.v1.json +15 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-operator.v1.json +14 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-top-level-field.v1.json +42 -0
- package/fixtures/obsidian/contracts/scope/valid/explicit-empty.v1.json +8 -0
- package/fixtures/obsidian/contracts/scope/valid/focus.v1.json +11 -0
- package/fixtures/obsidian/contracts/scope/valid/full.v1.json +8 -0
- package/fixtures/obsidian/contracts/scope/valid/scoped-set-expression.v1.json +41 -0
- package/fixtures/obsidian/contracts/service-state/invalid/hostname-instead-of-literal-loopback.v1.json +23 -0
- package/fixtures/obsidian/contracts/service-state/invalid/missing-consent.v1.json +18 -0
- package/fixtures/obsidian/contracts/service-state/invalid/privileged-port.v1.json +23 -0
- package/fixtures/obsidian/contracts/service-state/invalid/unknown-top-level-field.v1.json +24 -0
- package/fixtures/obsidian/contracts/service-state/invalid/wildcard-bind.v1.json +23 -0
- package/fixtures/obsidian/contracts/service-state/valid/healthy.v1.json +23 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/absolute-file-path.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/duplicate-repository-id.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/mixed-read.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/parent-traversal-path.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/unknown-file-field.v1.json +49 -0
- package/fixtures/obsidian/contracts/source-snapshot/valid/two-repositories.v1.json +48 -0
- package/fixtures/obsidian/edits/workspace.json +65 -0
- package/fixtures/obsidian/materialization/expected-full.json +99 -0
- package/fixtures/obsidian/materialization/expected-scoped.json +39 -0
- package/fixtures/obsidian/materialization/workspace.json +54 -0
- package/fixtures/obsidian/proposals/expected-shapes.json +11 -0
- package/fixtures/ui/presentation/pane.v1.json +7 -0
- package/fixtures/ui/presentation/reference.v1.json +293 -0
- package/package.json +61 -6
- package/skills/claude/atelier-guided-coauthor/SKILL.md +49 -0
- package/skills/claude/atelier-guided-upgrade/SKILL.md +97 -0
- package/skills/claude/atelier-open/SKILL.md +5 -0
- package/skills/claude/atelier-skill-steward/SKILL.md +62 -0
- package/skills/claude/mnstry-readiness/SKILL.md +5 -0
- package/skills/codex/atelier-guided-coauthor/SKILL.md +49 -0
- package/skills/codex/atelier-guided-upgrade/SKILL.md +97 -0
- package/skills/codex/atelier-open/SKILL.md +5 -0
- package/skills/codex/atelier-skill-steward/SKILL.md +62 -0
- package/skills/codex/mnstry-readiness/SKILL.md +5 -0
- package/src/access/preview-enforcer.mjs +84 -0
- package/src/boundary/content-rules.mjs +9 -4
- package/src/boundary/policy.mjs +74 -42
- package/src/cli/project-options.mjs +58 -0
- package/src/cli/run.mjs +61 -3
- package/src/coauthor/session.mjs +161 -0
- package/src/coauthor/store.mjs +173 -0
- package/src/collaboration/event-ledger.mjs +16 -33
- package/src/collaboration/inspection-bundle.mjs +223 -0
- package/src/collaboration/review-contracts.mjs +25 -0
- package/src/collaboration/review-store.mjs +305 -0
- package/src/commands/coauthor.mjs +26 -0
- package/src/commands/config.mjs +6 -1
- package/src/commands/extension-pack.mjs +15 -8
- package/src/commands/init.mjs +44 -5
- package/src/commands/obsidian.mjs +368 -0
- package/src/commands/review.mjs +147 -0
- package/src/commands/setup.mjs +51 -14
- package/src/commands/skills.mjs +199 -0
- package/src/commands/sync.mjs +107 -0
- package/src/composition/registry.mjs +27 -0
- package/src/composition/wire.mjs +122 -0
- package/src/contracts/corpus.mjs +35 -0
- package/src/decisions/contracts.d.mts +96 -0
- package/src/decisions/contracts.mjs +321 -0
- package/src/disclosure/content-scan.mjs +13 -0
- package/src/export/atelier-export-contract.mjs +16 -1
- package/src/extension-packs/lifecycle.mjs +154 -0
- package/src/graph/graph.mjs +64 -2
- package/src/graph/knowledge-graph.mjs +548 -24
- package/src/guides/contracts.mjs +54 -0
- package/src/index.mjs +29 -0
- package/src/intake/store.mjs +119 -0
- package/src/preview/channel.mjs +89 -0
- package/src/project/config.mjs +50 -49
- package/src/project/durable-state.mjs +161 -0
- package/src/project/file-class.mjs +166 -0
- package/src/project/private-state.mjs +11 -1
- package/src/projection/obsidian/contracts.mjs +571 -0
- package/src/projection/obsidian/edits/align.mjs +169 -0
- package/src/projection/obsidian/edits/apply.mjs +889 -0
- package/src/projection/obsidian/edits/arbitrate.mjs +414 -0
- package/src/projection/obsidian/edits/contribution.mjs +108 -0
- package/src/projection/obsidian/edits/index.mjs +19 -0
- package/src/projection/obsidian/edits/object-identity.mjs +61 -0
- package/src/projection/obsidian/edits/object-store.mjs +517 -0
- package/src/projection/obsidian/edits/observe.mjs +123 -0
- package/src/projection/obsidian/edits/policy.mjs +123 -0
- package/src/projection/obsidian/edits/regions.mjs +648 -0
- package/src/projection/obsidian/materialize/byte-lens.mjs +98 -0
- package/src/projection/obsidian/materialize/index.mjs +7 -0
- package/src/projection/obsidian/materialize/path-registry.mjs +105 -0
- package/src/projection/obsidian/materialize/prepare-view.mjs +746 -0
- package/src/projection/obsidian/materialize/settings.mjs +57 -0
- package/src/projection/obsidian/materialize/stage.mjs +27 -0
- package/src/projection/obsidian/proposals/adapter.mjs +572 -0
- package/src/projection/obsidian/proposals/backpressure.mjs +113 -0
- package/src/projection/obsidian/proposals/contribution.mjs +55 -0
- package/src/projection/obsidian/proposals/index.mjs +23 -0
- package/src/projection/obsidian/proposals/observation.mjs +216 -0
- package/src/projection/obsidian/proposals/queue.mjs +277 -0
- package/src/projection/obsidian/proposals/router.mjs +168 -0
- package/src/projection/obsidian/publication/bridge-script.mjs +266 -0
- package/src/projection/obsidian/publication/exchange.mjs +117 -0
- package/src/projection/obsidian/publication/index.mjs +5 -0
- package/src/projection/obsidian/publication/publisher.mjs +588 -0
- package/src/projection/obsidian/publication/test-seam.mjs +16 -0
- package/src/projection/obsidian/publication/transport.mjs +139 -0
- package/src/projection/obsidian/recovery/index.mjs +6 -0
- package/src/projection/obsidian/recovery/journal.mjs +134 -0
- package/src/projection/obsidian/recovery/late-writer.mjs +46 -0
- package/src/projection/obsidian/recovery/restart.mjs +316 -0
- package/src/projection/obsidian/recovery/store.mjs +234 -0
- package/src/projection/obsidian/selection-ui/conflict-view.mjs +91 -0
- package/src/projection/obsidian/selection-ui/contribution.mjs +141 -0
- package/src/projection/obsidian/selection-ui/focus.mjs +82 -0
- package/src/projection/obsidian/selection-ui/index.mjs +12 -0
- package/src/projection/obsidian/selection-ui/policy-setup.mjs +107 -0
- package/src/projection/obsidian/selection-ui/receipt.mjs +173 -0
- package/src/projection/obsidian/selection-ui/selection-state.mjs +122 -0
- package/src/projection/obsidian/selection-ui/selection.mjs +109 -0
- package/src/projection/project.mjs +28 -19
- package/src/readiness-protocols/evidence.mjs +282 -0
- package/src/readiness-protocols/runtime.mjs +4 -3
- package/src/readiness-protocols/source-read.mjs +39 -0
- package/src/runtime/git-adapter.mjs +189 -0
- package/src/runtime/local-state.mjs +439 -0
- package/src/runtime/obsidian/app-capability.mjs +110 -0
- package/src/runtime/obsidian/app-production-seams.mjs +96 -0
- package/src/runtime/obsidian/contributions/proposal-adapter.mjs +4 -0
- package/src/runtime/obsidian/contributions/selection-ui.mjs +4 -0
- package/src/runtime/obsidian/contributions/source-apply.mjs +4 -0
- package/src/runtime/obsidian/contributions.mjs +27 -0
- package/src/runtime/obsidian/documents.mjs +37 -0
- package/src/runtime/obsidian/enablement.mjs +53 -0
- package/src/runtime/obsidian/engine.mjs +532 -0
- package/src/runtime/obsidian/errors.mjs +14 -0
- package/src/runtime/obsidian/extension-points.mjs +103 -0
- package/src/runtime/obsidian/index.mjs +34 -0
- package/src/runtime/obsidian/lifecycle.mjs +258 -0
- package/src/runtime/obsidian/machine-settings.mjs +260 -0
- package/src/runtime/obsidian/observation.mjs +158 -0
- package/src/runtime/obsidian/opening.mjs +243 -0
- package/src/runtime/obsidian/pending-edits.mjs +141 -0
- package/src/runtime/obsidian/pipeline.mjs +122 -0
- package/src/runtime/obsidian/private-lock.mjs +156 -0
- package/src/runtime/obsidian/process-identity.mjs +58 -0
- package/src/runtime/obsidian/service-client.mjs +66 -0
- package/src/runtime/obsidian/service-main.mjs +89 -0
- package/src/runtime/obsidian/service-record.mjs +169 -0
- package/src/runtime/obsidian/service-server.mjs +121 -0
- package/src/runtime/obsidian/service.mjs +216 -0
- package/src/runtime/obsidian/startup-adapters.mjs +87 -0
- package/src/runtime/obsidian/state-store.mjs +180 -0
- package/src/runtime/obsidian/sync-notice.mjs +33 -0
- package/src/runtime/obsidian/tick-loop.mjs +84 -0
- package/src/runtime/obsidian/watchers.mjs +36 -0
- package/src/runtime/repository-observation.mjs +491 -0
- package/src/runtime/supervisor.mjs +803 -0
- package/src/server/local-sidecar.mjs +44 -0
- package/src/server/server.mjs +1 -0
- package/src/skills/steward.mjs +780 -0
- package/src/ui/presentation/browser.mjs +278 -0
- package/src/ui/presentation/contract.mjs +116 -0
- package/src/ui/presentation/index.mjs +7 -0
- package/src/ui/presentation/native.mjs +153 -0
- package/src/ui/presentation/proof.mjs +57 -0
- package/src/ui/presentation/schema-check.mjs +30 -0
- package/src/ui/presentation/schema.generated.mjs +1371 -0
- package/src/ui/presentation/state.mjs +43 -0
- package/src/ui/presentation/styles.mjs +67 -0
- package/src/ui/presentation/tokens.mjs +74 -0
- package/src/ui/presentation/web.mjs +85 -0
- package/src/ui/review-page.mjs +32 -0
- package/src/upgrade/explanation.mjs +33 -0
- package/src/upgrade/provenance.mjs +338 -0
- package/src/upgrade/transaction-files.mjs +103 -0
- package/src/upgrade/transaction.mjs +475 -0
- package/src/upgrade/upgrade.mjs +63 -28
- package/src/vault/hosts.mjs +21 -0
- package/src/vault/identity.mjs +19 -0
- package/src/vault/index.mjs +7 -0
- package/src/vault/interface.mjs +11 -0
- package/src/vault/metadata.mjs +31 -0
- package/src/vault/privacy.mjs +148 -0
- package/src/vault/probe.mjs +89 -0
- package/src/vault/service.mjs +131 -0
- package/src/vault/source.mjs +45 -0
- package/src/vault/storage.mjs +23 -0
- package/templates/distribution-workspace/.claude/launch.json +6 -2
- package/templates/distribution-workspace/gitignore +1 -0
- package/templates/distribution-workspace/project/README.md +2 -2
- package/templates/external-project-workspace/README.md +45 -0
- package/templates/external-project-workspace/answers.example.json +4 -0
- package/templates/external-project-workspace/atelier.project.json +40 -0
- package/templates/external-project-workspace/boundary-policy.v1.json +60 -0
- package/templates/external-project-workspace/gitignore +8 -0
- package/templates/external-project-workspace/pack-lifecycle.json +14 -0
- package/templates/external-project-workspace/packs/protocols/contract-gate.v1.json +109 -0
- package/templates/external-project-workspace/packs/sample-pack.v1.json +48 -0
- package/templates/external-project-workspace/repo-access.v1.json +9 -0
- package/templates/external-project-workspace/source/README.md +11 -0
- package/templates/launch.json +6 -2
- package/templates/private-domain-workspace/.claude/launch.json +6 -2
- package/templates/private-domain-workspace/domain/README.md +2 -2
- package/templates/private-domain-workspace/gitignore +1 -0
- package/templates/shared-project-workspace/.claude/launch.json +6 -2
- package/templates/shared-project-workspace/gitignore +1 -0
- package/templates/shared-project-workspace/project/README.md +2 -2
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { unclosedFenceAtEnd } from '../../../graph/knowledge-graph.mjs'
|
|
3
|
+
import { DERIVED_RELATION_TYPE, RELATION_TYPES, SCOPE_PRIMITIVES, assertObsidianContract, identitySuffix, readableTitle, selectScope } from '../contracts.mjs'
|
|
4
|
+
import { assertStrictUtf8, readMarkdownLens, refuse, sha256Digest } from './byte-lens.mjs'
|
|
5
|
+
import { allocateWorkspacePaths, collisionKey, emptyPathRegistry, titleWithinBudget } from './path-registry.mjs'
|
|
6
|
+
import { isUserOwnedSettingsPath, prepareSettings } from './settings.mjs'
|
|
7
|
+
|
|
8
|
+
// prepareView: a pure preparation of one Obsidian view. It reads sources
|
|
9
|
+
// through the caller, returns note, attachment and settings bytes plus a
|
|
10
|
+
// generation manifest, and writes nothing. The canonical graph is the only
|
|
11
|
+
// graph authority: notes and relation rows serialize canonical nodes and
|
|
12
|
+
// edges, and only canonical resolved link and embed occurrences are rewritten.
|
|
13
|
+
// An embedded asset is copied only when the canonical graph resolved it; the
|
|
14
|
+
// emitter never infers one from authored bytes.
|
|
15
|
+
//
|
|
16
|
+
// Redaction happens where bytes are made. Every title, path and identity that
|
|
17
|
+
// reaches an output is looked up through `vault`, the set selectScope returned
|
|
18
|
+
// for this view, so a node outside it cannot be serialized by any branch.
|
|
19
|
+
//
|
|
20
|
+
// Preparation is incremental when the caller passes a preparation cache (see
|
|
21
|
+
// createPreparationCache). Every note's bytes and manifest entry are a pure
|
|
22
|
+
// function of a small set of inputs: the pinned source digest, the node
|
|
23
|
+
// record, the allocated path, the generated rows, the outside-selection count
|
|
24
|
+
// and the rewritten occurrences with their emitted targets. Those inputs are
|
|
25
|
+
// serialized into a dependency key per note; a note whose key matches the
|
|
26
|
+
// cached one reuses the cached bytes and manifest entry instead of being
|
|
27
|
+
// emitted again, so the result is byte-identical to a full preparation by
|
|
28
|
+
// construction. The cache holds derived state only and can be dropped at any
|
|
29
|
+
// time; the redaction guard still runs over the whole result.
|
|
30
|
+
|
|
31
|
+
export const EMITTER_VERSION = '1.0.0'
|
|
32
|
+
const EXT_KEY = 'mnstry.atelier.obsidian'
|
|
33
|
+
const EMBEDDABLE = new Set(['pdf', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'])
|
|
34
|
+
// Bytes left for an asset's readable stem beside the longest suffix and extension.
|
|
35
|
+
const ASSET_STEM_BYTE_BUDGET = 255 - '--'.length - 64 - '.'.length - 16
|
|
36
|
+
const RELATIONS_HEADING = '## Relations (generated)\n\n%% Generated from the canonical graph. Edits to this section are not applied to any source. %%\n\n'
|
|
37
|
+
|
|
38
|
+
const compare = (left, right) => (left < right ? -1 : left > right ? 1 : 0)
|
|
39
|
+
const utf8 = (text) => Buffer.from(text, 'utf8')
|
|
40
|
+
const base64url = (bytes) => Buffer.from(bytes).toString('base64url')
|
|
41
|
+
|
|
42
|
+
// Text placed in generated prose. Anything that could start a link, a tag, an
|
|
43
|
+
// embed or a comment is removed or escaped, so generated prose can never add a
|
|
44
|
+
// native graph edge that the canonical graph does not hold.
|
|
45
|
+
function plainText(value) {
|
|
46
|
+
return String(value ?? '')
|
|
47
|
+
.normalize('NFC')
|
|
48
|
+
.replace(/[\u0000-\u001f\u007f\u2028\u2029]/g, ' ')
|
|
49
|
+
.replace(/\s+/g, ' ')
|
|
50
|
+
.trim()
|
|
51
|
+
.replace(/[\\`*_[\]<>#|^$%~=!&]/g, (character) => `\\${character}`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function encodeHref(fileName) {
|
|
55
|
+
return encodeURIComponent(fileName).replace(/[()']/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const noteBasename = (notePathValue) => notePathValue.slice('notes/'.length, -'.md'.length)
|
|
59
|
+
|
|
60
|
+
function edgeIdentifier(edge) {
|
|
61
|
+
return `e-${createHash('sha256').update(`${edge.source}\u0000${edge.type}\u0000${edge.target}`).digest('hex').slice(0, 32)}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function timestampFrom(clock) {
|
|
65
|
+
if (typeof clock !== 'function') refuse('missing-clock', 'prepareView needs an injected clock for the manifest freshness timestamp')
|
|
66
|
+
const value = clock()
|
|
67
|
+
const date = value instanceof Date ? value : new Date(value)
|
|
68
|
+
if (Number.isNaN(date.getTime())) refuse('missing-clock', 'the injected clock did not return a time')
|
|
69
|
+
return date.toISOString()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Sources
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
function sourceReader(snapshot) {
|
|
77
|
+
const expected = new Map()
|
|
78
|
+
for (const repo of snapshot.document.repositories) {
|
|
79
|
+
for (const file of repo.files) expected.set(`${repo.repoId}\u0000${file.path}`, file)
|
|
80
|
+
}
|
|
81
|
+
const pinnedFor = (repoId, relativePath) => {
|
|
82
|
+
const pinned = expected.get(`${repoId}\u0000${relativePath}`)
|
|
83
|
+
if (!pinned) refuse('source-not-in-snapshot', 'a selected source is not pinned by the source snapshot')
|
|
84
|
+
return pinned
|
|
85
|
+
}
|
|
86
|
+
const read = (repoId, relativePath) => {
|
|
87
|
+
const pinned = pinnedFor(repoId, relativePath)
|
|
88
|
+
const bytes = snapshot.readSource(repoId, relativePath)
|
|
89
|
+
if (!Buffer.isBuffer(bytes)) refuse('invalid-source', 'readSource must return a Buffer')
|
|
90
|
+
if (bytes.length !== pinned.byteLength || sha256Digest(bytes) !== pinned.rawDigest) {
|
|
91
|
+
refuse('mixed-read', 'source bytes differ from the digest pinned by the source snapshot')
|
|
92
|
+
}
|
|
93
|
+
return { bytes, rawDigest: pinned.rawDigest }
|
|
94
|
+
}
|
|
95
|
+
read.pinned = pinnedFor
|
|
96
|
+
return read
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Preparation cache
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
// Derived, droppable state for incremental preparation: one entry per note
|
|
104
|
+
// path holding the dependency key the note was emitted under and everything
|
|
105
|
+
// the note contributed to the result. It is private to the process that made
|
|
106
|
+
// it and is never written anywhere.
|
|
107
|
+
export function createPreparationCache() {
|
|
108
|
+
return { notes: new Map() }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isPreparationCache(cache) {
|
|
112
|
+
return cache !== null && typeof cache === 'object' && cache.notes instanceof Map
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Everything the emitted bytes and the manifest entry of one note depend on,
|
|
116
|
+
// in a canonical serialization. A cached note is reused only under an equal
|
|
117
|
+
// key. The emitter version is part of the key so an entry states which emitter
|
|
118
|
+
// produced it, should a cache ever outlive this module.
|
|
119
|
+
function dependencyKey({ node, notePathValue, pinned, rows, outsideCount, occurrences, emittedTarget }) {
|
|
120
|
+
// A wrapper note serializes the record's summary and tags; a Markdown note never does.
|
|
121
|
+
const wrapper = node.extension === 'md' ? null : JSON.stringify([String(node.summary ?? ''), Array.isArray(node.tags) ? node.tags : null])
|
|
122
|
+
const parts = [
|
|
123
|
+
EMITTER_VERSION, notePathValue, node.repo, node.id, node.path, String(node.extension), String(node.title ?? ''), String(wrapper),
|
|
124
|
+
pinned.rawDigest, String(pinned.byteLength), String(outsideCount), rows.join(''),
|
|
125
|
+
]
|
|
126
|
+
for (const occurrence of occurrences) {
|
|
127
|
+
const target = emittedTarget(occurrence)
|
|
128
|
+
parts.push(JSON.stringify([
|
|
129
|
+
occurrence.type ?? null, occurrence.syntax ?? null, occurrence.embed === true, occurrence.href ?? null, occurrence.target ?? null,
|
|
130
|
+
occurrence.range?.byteStart ?? null, occurrence.range?.byteEnd ?? null, occurrence.targetRange?.byteStart ?? null, occurrence.targetRange?.byteEnd ?? null,
|
|
131
|
+
target.key, target.markdown, target.wikilink, target.alias,
|
|
132
|
+
]))
|
|
133
|
+
}
|
|
134
|
+
// JSON escapes every control character, so no field can shift into its neighbour.
|
|
135
|
+
return createHash('sha256').update(JSON.stringify(parts)).digest('hex')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Link rewriting
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
// The byte edits for one source: each is { start, end, emitted } over the
|
|
143
|
+
// source Buffer. Offsets come from the canonical occurrence; the bytes under
|
|
144
|
+
// them are compared with what the graph read, so a graph built from different
|
|
145
|
+
// bytes refuses instead of rewriting the wrong place. `emittedTarget` names
|
|
146
|
+
// what an occurrence is rewritten to: { key, markdown, wikilink, alias }.
|
|
147
|
+
// Links and asset embeds go through here together, so one overlap check covers
|
|
148
|
+
// both.
|
|
149
|
+
function linkEdits({ source, lens, occurrences, emittedTarget }) {
|
|
150
|
+
const edits = []
|
|
151
|
+
for (const occurrence of occurrences) {
|
|
152
|
+
const { range, targetRange } = occurrence
|
|
153
|
+
const offsets = [range?.byteStart, targetRange?.byteStart, targetRange?.byteEnd, range?.byteEnd]
|
|
154
|
+
if (offsets.some((offset) => !Number.isInteger(offset)) || offsets.some((offset, index) => index > 0 && offset < offsets[index - 1])) {
|
|
155
|
+
refuse('link-offsets-unavailable', 'a canonical link occurrence carries no usable byte offsets')
|
|
156
|
+
}
|
|
157
|
+
if (range.byteStart < lens.body.start || range.byteEnd > lens.body.end) refuse('link-outside-body', 'a canonical link occurrence lies outside the authored body')
|
|
158
|
+
const written = source.subarray(targetRange.byteStart, targetRange.byteEnd).toString('utf8')
|
|
159
|
+
const target = emittedTarget(occurrence)
|
|
160
|
+
if (occurrence.syntax === 'markdown') {
|
|
161
|
+
if (written !== occurrence.href) refuse('mixed-read', 'source bytes under a canonical link differ from the link the graph read')
|
|
162
|
+
edits.push({ ...target.key, start: targetRange.byteStart, end: targetRange.byteEnd, emitted: utf8(target.markdown) })
|
|
163
|
+
} else if (occurrence.syntax === 'wikilink') {
|
|
164
|
+
const whole = source.subarray(range.byteStart, range.byteEnd).toString('utf8')
|
|
165
|
+
if (written.trim() !== occurrence.href || !whole.startsWith('[[') || !whole.endsWith(']]')) {
|
|
166
|
+
refuse('mixed-read', 'source bytes under a canonical link differ from the link the graph read')
|
|
167
|
+
}
|
|
168
|
+
edits.push({ ...target.key, start: targetRange.byteStart, end: targetRange.byteEnd, emitted: utf8(target.wikilink) })
|
|
169
|
+
// Keep the words the author chose visible: without an alias the editor
|
|
170
|
+
// would display the allocated file name. An embed has no label: what
|
|
171
|
+
// follows its target (a size, a fragment) stays as authored.
|
|
172
|
+
const tail = source.subarray(targetRange.byteEnd, range.byteEnd - 2).toString('utf8')
|
|
173
|
+
if (target.alias && !occurrence.embed && !tail.includes('|')) {
|
|
174
|
+
edits.push({ ...target.key, start: range.byteEnd - 2, end: range.byteEnd - 2, emitted: utf8(`|${occurrence.href}`) })
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
refuse('unsupported-link', 'a canonical link occurrence uses an unknown syntax')
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
edits.sort((left, right) => left.start - right.start || left.end - right.end)
|
|
181
|
+
for (let index = 1; index < edits.length; index += 1) {
|
|
182
|
+
if (edits[index].start < edits[index - 1].end) refuse('link-overlap', 'canonical link occurrences overlap')
|
|
183
|
+
}
|
|
184
|
+
return edits
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Applies edits to the body and returns the emitted bytes plus, for each edit,
|
|
188
|
+
// its source range, its range in `prefixLength`-offset note space and both
|
|
189
|
+
// byte strings.
|
|
190
|
+
function applyEdits({ source, from, to, edits, noteOffset }) {
|
|
191
|
+
const parts = []
|
|
192
|
+
const inversions = []
|
|
193
|
+
let cursor = from
|
|
194
|
+
let written = noteOffset
|
|
195
|
+
for (const edit of edits) {
|
|
196
|
+
const kept = source.subarray(cursor, edit.start)
|
|
197
|
+
parts.push(kept)
|
|
198
|
+
written += kept.length
|
|
199
|
+
parts.push(edit.emitted)
|
|
200
|
+
inversions.push({
|
|
201
|
+
edgeKey: edit.edgeKey,
|
|
202
|
+
assetKey: edit.assetKey,
|
|
203
|
+
source: { start: edit.start, end: edit.end },
|
|
204
|
+
note: { start: written, end: written + edit.emitted.length },
|
|
205
|
+
ext: { [EXT_KEY]: { original: base64url(source.subarray(edit.start, edit.end)), emitted: base64url(edit.emitted), encoding: 'base64url' } },
|
|
206
|
+
})
|
|
207
|
+
written += edit.emitted.length
|
|
208
|
+
cursor = edit.end
|
|
209
|
+
}
|
|
210
|
+
parts.push(source.subarray(cursor, to))
|
|
211
|
+
return { bytes: Buffer.concat(parts), inversions }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Generated sections
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
function separatorAfter(bytes) {
|
|
219
|
+
if (bytes.length === 0) return ''
|
|
220
|
+
return bytes[bytes.length - 1] === 0x0a ? '\n' : '\n\n'
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// `titles` memoizes the two renderings of a title per node for one
|
|
224
|
+
// preparation: a node's title is rendered once however many rows name it.
|
|
225
|
+
function titleRenderings() {
|
|
226
|
+
const readable = new Map()
|
|
227
|
+
const plain = new Map()
|
|
228
|
+
return {
|
|
229
|
+
readableOf: (target) => { if (!readable.has(target.id)) readable.set(target.id, readableTitle(target.title)); return readable.get(target.id) },
|
|
230
|
+
plainOf: (origin) => { if (!plain.has(origin.id)) plain.set(origin.id, plainText(origin.title)); return plain.get(origin.id) },
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function relationRows({ node, outgoing, incoming, vaultNode, pathOf, titles }) {
|
|
235
|
+
const rows = []
|
|
236
|
+
for (const type of RELATION_TYPES) {
|
|
237
|
+
for (const edge of outgoing) {
|
|
238
|
+
if (edge.type !== type) continue
|
|
239
|
+
const target = vaultNode(edge.target)
|
|
240
|
+
if (!target) continue
|
|
241
|
+
rows.push(`- ${type} → [[${noteBasename(pathOf(target))}|${titles.readableOf(target)}]]\n`)
|
|
242
|
+
}
|
|
243
|
+
// An incoming row is plain text: a link here would give this note an
|
|
244
|
+
// outgoing native link that the canonical graph does not hold.
|
|
245
|
+
if (type === DERIVED_RELATION_TYPE) continue
|
|
246
|
+
for (const edge of incoming) {
|
|
247
|
+
if (edge.type !== type) continue
|
|
248
|
+
const origin = vaultNode(edge.source)
|
|
249
|
+
if (!origin || origin.id === node.id) continue
|
|
250
|
+
rows.push(`- ${type} ← ${titles.plainOf(origin)}\n`)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return rows
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// An authored body that ends inside a fenced code block would swallow whatever
|
|
257
|
+
// follows it. The closing fence is generated, never authored: it is the first
|
|
258
|
+
// bytes of the first generated region, so authored ranges and inversion stay
|
|
259
|
+
// exact. It repeats the opener's indentation, character and length, starts on
|
|
260
|
+
// its own line and uses the source's line ending. The fence rules are the
|
|
261
|
+
// canonical graph scanner's, not restated here.
|
|
262
|
+
function fenceClosureFor(source, finalNewline) {
|
|
263
|
+
const fence = unclosedFenceAtEnd(source.toString('utf8'))
|
|
264
|
+
if (!fence) return null
|
|
265
|
+
const firstBreak = source.indexOf(0x0a)
|
|
266
|
+
const crlf = finalNewline === 'crlf' || (finalNewline === 'none' && firstBreak > 0 && source[firstBreak - 1] === 0x0d)
|
|
267
|
+
const eol = crlf ? '\r\n' : '\n'
|
|
268
|
+
const line = `${' '.repeat(fence.indent)}${fence.char.repeat(fence.length)}`
|
|
269
|
+
return { fence: line, text: `${finalNewline === 'none' ? eol : ''}${line}${eol}` }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function generatedSections({ precedingBytes, offset, rows, outsideCount, closure = null }) {
|
|
273
|
+
const regions = []
|
|
274
|
+
const parts = []
|
|
275
|
+
let cursor = offset
|
|
276
|
+
const push = (kind, text) => {
|
|
277
|
+
const closes = closure && regions.length === 0
|
|
278
|
+
const bytes = utf8(closes ? `${closure.text}${text}` : text)
|
|
279
|
+
parts.push(bytes)
|
|
280
|
+
regions.push({
|
|
281
|
+
kind,
|
|
282
|
+
range: { start: cursor, end: cursor + bytes.length },
|
|
283
|
+
...(closes ? { ext: { [EXT_KEY]: { fenceClosure: { fence: closure.fence, byteLength: Buffer.byteLength(closure.text, 'utf8') } } } } : {}),
|
|
284
|
+
})
|
|
285
|
+
cursor += bytes.length
|
|
286
|
+
}
|
|
287
|
+
if (closure) precedingBytes = utf8(closure.text)
|
|
288
|
+
if (rows.length > 0) push('relations', `${separatorAfter(precedingBytes)}${RELATIONS_HEADING}${rows.join('')}`)
|
|
289
|
+
if (outsideCount > 0) {
|
|
290
|
+
const lead = rows.length > 0 ? '\n' : `${separatorAfter(precedingBytes)}${RELATIONS_HEADING}`
|
|
291
|
+
push('outside-selection', `${lead}Relationships leading outside this view: ${outsideCount}\n`)
|
|
292
|
+
}
|
|
293
|
+
return { bytes: Buffer.concat(parts), regions }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function wrapperRepresentation({ node, attachmentPath }) {
|
|
297
|
+
const lines = [`# ${plainText(node.title)}\n`]
|
|
298
|
+
if (node.summary) lines.push(`\n${plainText(node.summary)}\n`)
|
|
299
|
+
lines.push(`\n- Format: ${plainText(node.extension)}\n`)
|
|
300
|
+
lines.push(`- Source: ${plainText(node.repo)} · ${node.path.split('/').map(plainText).join(' / ')}\n`)
|
|
301
|
+
if (Array.isArray(node.tags) && node.tags.length > 0) lines.push(`- Tags: ${node.tags.map(plainText).join(', ')}\n`)
|
|
302
|
+
lines.push(`- Original: [[${attachmentPath}|Open the original file]]\n`)
|
|
303
|
+
if (EMBEDDABLE.has(String(node.extension).toLowerCase())) lines.push(`\n![[${attachmentPath}]]\n`)
|
|
304
|
+
return utf8(lines.join(''))
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
// prepareView
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
function assertContained(relativePath) {
|
|
312
|
+
const parts = relativePath.split('/')
|
|
313
|
+
if (relativePath.startsWith('/') || relativePath.includes('\\') || relativePath.includes('\u0000') || parts.some((part) => part === '' || part === '.' || part === '..')) {
|
|
314
|
+
refuse('path-escapes-vault', 'a prepared path would leave the vault root')
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// One note, from its inputs alone: the prepared files (the note and, for a
|
|
319
|
+
// wrapper, its attachment), the manifest entry, the attachment record, the
|
|
320
|
+
// inversions it contributes per rewritten edge, and whether it closed a fence.
|
|
321
|
+
// Nothing outside the arguments is read, which is what lets the result be
|
|
322
|
+
// cached under the dependency key.
|
|
323
|
+
function emitNote({ node, notePathValue, source: { bytes: source, rawDigest }, rows, outsideCount, occurrences, emittedTarget, key }) {
|
|
324
|
+
const sourceRecord = { path: node.path, rawDigest, byteLength: source.length }
|
|
325
|
+
const assetInversions = new Map()
|
|
326
|
+
const edgeInversions = new Map()
|
|
327
|
+
const files = []
|
|
328
|
+
let attachment = null
|
|
329
|
+
let authored
|
|
330
|
+
let regions
|
|
331
|
+
let closure = null
|
|
332
|
+
|
|
333
|
+
if (node.extension === 'md') {
|
|
334
|
+
const lens = readMarkdownLens(source, { repoId: node.repo, nodeId: node.id })
|
|
335
|
+
const edits = linkEdits({ source, lens, occurrences, emittedTarget })
|
|
336
|
+
const prefix = source.subarray(0, lens.body.start)
|
|
337
|
+
const body = applyEdits({ source, from: lens.body.start, to: lens.body.end, edits, noteOffset: prefix.length })
|
|
338
|
+
authored = Buffer.concat([prefix, body.bytes])
|
|
339
|
+
for (const { edgeKey, assetKey, ...inversion } of body.inversions) {
|
|
340
|
+
if (assetKey !== undefined) {
|
|
341
|
+
if (!assetInversions.has(assetKey)) assetInversions.set(assetKey, [])
|
|
342
|
+
assetInversions.get(assetKey).push(inversion)
|
|
343
|
+
continue
|
|
344
|
+
}
|
|
345
|
+
if (!edgeInversions.has(edgeKey)) edgeInversions.set(edgeKey, [])
|
|
346
|
+
edgeInversions.get(edgeKey).push(inversion)
|
|
347
|
+
}
|
|
348
|
+
regions = {
|
|
349
|
+
...(lens.frontmatter ? { frontmatter: lens.frontmatter } : {}),
|
|
350
|
+
body: { start: lens.body.start, end: authored.length },
|
|
351
|
+
}
|
|
352
|
+
closure = fenceClosureFor(source, lens.finalNewline)
|
|
353
|
+
Object.assign(sourceRecord, { kind: 'markdown', finalNewline: lens.finalNewline, ...(lens.bom ? { bom: lens.bom } : {}) })
|
|
354
|
+
} else {
|
|
355
|
+
const extension = /^[a-z0-9]{1,16}$/.test(String(node.extension).toLowerCase()) ? String(node.extension).toLowerCase() : 'bin'
|
|
356
|
+
const attachmentPath = `attachments/${noteBasename(notePathValue)}.${extension}`
|
|
357
|
+
assertContained(attachmentPath)
|
|
358
|
+
attachment = { path: attachmentPath, digest: rawDigest, byteLength: source.length }
|
|
359
|
+
files.push({ path: attachmentPath, kind: 'attachment', bytes: source, digest: rawDigest })
|
|
360
|
+
authored = wrapperRepresentation({ node, attachmentPath })
|
|
361
|
+
regions = { body: { start: authored.length, end: authored.length }, representation: { start: 0, end: authored.length } }
|
|
362
|
+
Object.assign(sourceRecord, { kind: 'wrapper', attachment: attachmentPath })
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const generated = generatedSections({ precedingBytes: authored, offset: authored.length, rows, outsideCount, closure })
|
|
366
|
+
const bytes = Buffer.concat([authored, generated.bytes])
|
|
367
|
+
assertStrictUtf8(bytes)
|
|
368
|
+
assertContained(notePathValue)
|
|
369
|
+
const { representation, ...authoredRegions } = regions
|
|
370
|
+
files.push({ path: notePathValue, kind: 'note', bytes, digest: sha256Digest(bytes) })
|
|
371
|
+
const note = {
|
|
372
|
+
repoId: node.repo,
|
|
373
|
+
nodeId: node.id,
|
|
374
|
+
path: notePathValue,
|
|
375
|
+
title: String(node.title || 'Untitled').slice(0, 512),
|
|
376
|
+
noteDigest: sha256Digest(bytes),
|
|
377
|
+
regions: {
|
|
378
|
+
...authoredRegions,
|
|
379
|
+
generated: [...(representation ? [{ kind: 'representation', range: representation }] : []), ...generated.regions],
|
|
380
|
+
},
|
|
381
|
+
ext: {
|
|
382
|
+
[EXT_KEY]: {
|
|
383
|
+
source: sourceRecord,
|
|
384
|
+
...(assetInversions.size > 0
|
|
385
|
+
? { assetEmbeds: [...assetInversions].map(([attachment, inversions]) => ({ attachment, inversions })).sort((left, right) => compare(left.attachment, right.attachment)) }
|
|
386
|
+
: {}),
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
}
|
|
390
|
+
return { key, files, attachment, note, edgeInversions: [...edgeInversions], fenceClosed: Boolean(closure && generated.regions.length > 0) }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
// Embedded assets
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
// The assets this view may copy, by id. Fails closed like a node: only an
|
|
398
|
+
// explicit eligible: true, in a repository the profile enrols, under the
|
|
399
|
+
// repository's audience rule. A malformed or repeated record is withheld.
|
|
400
|
+
function visibleAssets(graph, profile) {
|
|
401
|
+
const view = { repositories: new Map(profile.repositories.map((repo) => [repo.repoId, repo])), allowedAudiences: new Set(profile.audience.allow) }
|
|
402
|
+
const seen = new Set()
|
|
403
|
+
const visible = new Map()
|
|
404
|
+
for (const asset of Array.isArray(graph.assets) ? graph.assets : []) {
|
|
405
|
+
if (!asset || typeof asset.id !== 'string' || asset.id === '' || typeof asset.repo !== 'string' || asset.repo === '' || typeof asset.path !== 'string' || asset.path === '') continue
|
|
406
|
+
if (seen.has(asset.id)) {
|
|
407
|
+
visible.delete(asset.id)
|
|
408
|
+
continue
|
|
409
|
+
}
|
|
410
|
+
seen.add(asset.id)
|
|
411
|
+
if (SCOPE_PRIMITIVES.isVisible({ eligible: asset.eligible, repo: asset.repo }, view)) visible.set(asset.id, asset)
|
|
412
|
+
}
|
|
413
|
+
return visible
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// attachments/<readable stem>--<identity suffix>.<ext>, from the repository and
|
|
417
|
+
// asset identity alone. The suffix lengthens only when two assets of this view
|
|
418
|
+
// would share a name on a case- or normalization-insensitive filesystem.
|
|
419
|
+
function allocateAssetPaths(assets) {
|
|
420
|
+
const taken = new Set()
|
|
421
|
+
const allocated = new Map()
|
|
422
|
+
for (const asset of [...assets].sort((left, right) => compare(left.repo, right.repo) || compare(left.id, right.id))) {
|
|
423
|
+
const name = asset.path.split('/').at(-1)
|
|
424
|
+
const dot = name.lastIndexOf('.')
|
|
425
|
+
const written = dot > 0 ? name.slice(dot + 1).toLowerCase() : ''
|
|
426
|
+
const extension = /^[a-z0-9]{1,16}$/.test(written) ? written : 'bin'
|
|
427
|
+
const stem = readableTitle(titleWithinBudget(dot > 0 ? name.slice(0, dot) : name, ASSET_STEM_BYTE_BUDGET))
|
|
428
|
+
let length = 12
|
|
429
|
+
let candidate = `attachments/${stem}--${identitySuffix(asset.repo, asset.id, length)}.${extension}`
|
|
430
|
+
while (taken.has(collisionKey(candidate))) {
|
|
431
|
+
length += 4
|
|
432
|
+
if (length > 64) refuse('path-collision', 'unable to allocate a distinct attachment path')
|
|
433
|
+
candidate = `attachments/${stem}--${identitySuffix(asset.repo, asset.id, length)}.${extension}`
|
|
434
|
+
}
|
|
435
|
+
taken.add(collisionKey(candidate))
|
|
436
|
+
assertContained(candidate)
|
|
437
|
+
allocated.set(asset.id, candidate)
|
|
438
|
+
}
|
|
439
|
+
return allocated
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function canonicalSnapshotOf(graph) {
|
|
443
|
+
if (!graph || !Array.isArray(graph.nodes) || !Array.isArray(graph.edges) || !Array.isArray(graph.links)) {
|
|
444
|
+
refuse('invalid-snapshot', 'snapshot.graph must carry canonical nodes, edges and links')
|
|
445
|
+
}
|
|
446
|
+
const known = new Set(graph.nodes.map((node) => node?.id))
|
|
447
|
+
// A declared relation may name an identity outside the census. It has no
|
|
448
|
+
// note to point at, so it is not part of any view.
|
|
449
|
+
const edges = graph.edges.filter((edge) => known.has(edge.source) && known.has(edge.target))
|
|
450
|
+
return { nodes: graph.nodes, edges, externalEdgeCount: graph.edges.length - edges.length }
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// A path the prior generation published stays allocated even when the caller
|
|
454
|
+
// lost the registry: the prior manifest seeds any identity the registry lacks.
|
|
455
|
+
function seededRegistry({ registry, priorManifest, workspaceId }) {
|
|
456
|
+
if (!priorManifest) return registry
|
|
457
|
+
const base = registry ?? emptyPathRegistry(workspaceId)
|
|
458
|
+
if (!Array.isArray(base.entries)) return base
|
|
459
|
+
const known = new Set(base.entries.map((entry) => `${entry?.repoId}\u0000${entry?.nodeId}`))
|
|
460
|
+
const recovered = priorManifest.notes.filter((note) => !known.has(`${note.repoId}\u0000${note.nodeId}`)).map(({ repoId, nodeId, path }) => ({ repoId, nodeId, path }))
|
|
461
|
+
return { ...base, entries: [...base.entries, ...recovered] }
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export function prepareView({ snapshot, profile, scope, persistentPathRegistry = null, priorManifest = null, existingSettings = null, clock, generationId, vaultRootBytes, maxFullPathBytes, cache = null } = {}) {
|
|
465
|
+
if (cache !== null && !isPreparationCache(cache)) refuse('invalid-preparation-cache', 'the preparation cache must come from createPreparationCache')
|
|
466
|
+
assertObsidianContract('corpus-profile', profile)
|
|
467
|
+
assertObsidianContract('scope', scope)
|
|
468
|
+
if (!snapshot || typeof snapshot.readSource !== 'function') refuse('invalid-snapshot', 'snapshot needs a source snapshot document, a canonical graph and readSource')
|
|
469
|
+
assertObsidianContract('source-snapshot', snapshot.document)
|
|
470
|
+
if (snapshot.document.workspaceId !== profile.workspaceId) refuse('invalid-snapshot', 'source snapshot and corpus profile name different workspaces')
|
|
471
|
+
if (priorManifest) {
|
|
472
|
+
assertObsidianContract('generation-manifest', priorManifest)
|
|
473
|
+
if (priorManifest.scopeId !== scope.scopeId) refuse('prior-manifest-mismatch', 'the prior manifest belongs to another scope')
|
|
474
|
+
}
|
|
475
|
+
const checkedAt = timestampFrom(clock)
|
|
476
|
+
const canonical = canonicalSnapshotOf(snapshot.graph)
|
|
477
|
+
|
|
478
|
+
// Selection and visibility come from the contract, never from this module.
|
|
479
|
+
const universe = selectScope({ canonicalSnapshot: canonical, profile, selector: { all: true }, mode: 'scoped' })
|
|
480
|
+
const selection = selectScope({ canonicalSnapshot: canonical, profile, selector: scope.selector, expansion: scope.expansion, mode: scope.mode })
|
|
481
|
+
const nodeById = new Map(canonical.nodes.map((node) => [node.id, node]))
|
|
482
|
+
const vault = new Set(selection.vaultNodes)
|
|
483
|
+
const vaultNode = (id) => (vault.has(id) ? nodeById.get(id) : null)
|
|
484
|
+
|
|
485
|
+
const { registry, pathOf: allocatedPath } = allocateWorkspacePaths({
|
|
486
|
+
registry: seededRegistry({ registry: persistentPathRegistry, priorManifest, workspaceId: profile.workspaceId }),
|
|
487
|
+
workspaceId: profile.workspaceId,
|
|
488
|
+
nodes: universe.nodes.map((id) => nodeById.get(id)),
|
|
489
|
+
...(vaultRootBytes === undefined ? {} : { vaultRootBytes }),
|
|
490
|
+
...(maxFullPathBytes === undefined ? {} : { maxFullPathBytes }),
|
|
491
|
+
})
|
|
492
|
+
const pathOf = (node) => allocatedPath(node.repo, node.id)
|
|
493
|
+
for (const note of priorManifest?.notes ?? []) {
|
|
494
|
+
const current = allocatedPath(note.repoId, note.nodeId)
|
|
495
|
+
if (current !== null && current !== note.path) refuse('path-registry-divergence', 'the path registry and the prior manifest disagree about an allocated path')
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const edgeById = new Map(canonical.edges.map((edge) => [edge.id, edge]))
|
|
499
|
+
const vaultEdges = selection.vaultEdges.map((id) => edgeById.get(id)).filter((edge) => vault.has(edge.source) && vault.has(edge.target))
|
|
500
|
+
const outsideEdges = selection.outsideSelectionEdges.map((id) => edgeById.get(id)).filter((edge) => vault.has(edge.source) !== vault.has(edge.target))
|
|
501
|
+
const vaultLinkPairs = new Set(vaultEdges.filter((edge) => edge.type === DERIVED_RELATION_TYPE).map((edge) => `${edge.source}\u0000${edge.target}`))
|
|
502
|
+
const occurrencesBySource = new Map()
|
|
503
|
+
for (const link of snapshot.graph.links) {
|
|
504
|
+
if (!vaultLinkPairs.has(`${link.source}\u0000${link.target}`)) continue
|
|
505
|
+
if (!occurrencesBySource.has(link.source)) occurrencesBySource.set(link.source, [])
|
|
506
|
+
occurrencesBySource.get(link.source).push(link)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Asset embeds: canonical occurrences whose source is a note of this view and
|
|
510
|
+
// whose asset this view may copy. Anything else is left exactly as authored
|
|
511
|
+
// and appears in no output.
|
|
512
|
+
const assets = visibleAssets(snapshot.graph, profile)
|
|
513
|
+
const embeddedAssets = new Map()
|
|
514
|
+
for (const embed of Array.isArray(snapshot.graph.embeds) ? snapshot.graph.embeds : []) {
|
|
515
|
+
const asset = assets.get(embed?.asset?.id)
|
|
516
|
+
if (!asset || !vault.has(embed.source) || nodeById.get(embed.source).extension !== 'md') continue
|
|
517
|
+
embeddedAssets.set(asset.id, asset)
|
|
518
|
+
if (!occurrencesBySource.has(embed.source)) occurrencesBySource.set(embed.source, [])
|
|
519
|
+
occurrencesBySource.get(embed.source).push(embed)
|
|
520
|
+
}
|
|
521
|
+
const assetPaths = allocateAssetPaths(embeddedAssets.values())
|
|
522
|
+
const emittedTarget = (occurrence) => {
|
|
523
|
+
if (occurrence.type === 'embeds_asset') {
|
|
524
|
+
const attachment = assetPaths.get(occurrence.asset.id)
|
|
525
|
+
return { key: { assetKey: attachment }, markdown: attachment.split('/').map(encodeHref).join('/'), wikilink: attachment, alias: false }
|
|
526
|
+
}
|
|
527
|
+
const stem = noteBasename(pathOf(vaultNode(occurrence.target)))
|
|
528
|
+
return { key: { edgeKey: occurrence.target }, markdown: encodeHref(`${stem}.md`), wikilink: stem, alias: true }
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const read = sourceReader(snapshot)
|
|
532
|
+
const orderedNodes = [...vault].map((id) => nodeById.get(id)).sort((left, right) => compare(left.repo, right.repo) || compare(left.id, right.id))
|
|
533
|
+
// Edges indexed by endpoint once, so a note's rows cost its degree and not
|
|
534
|
+
// the size of the view.
|
|
535
|
+
const outgoingBy = new Map()
|
|
536
|
+
const incomingBy = new Map()
|
|
537
|
+
const outsideCountBy = new Map()
|
|
538
|
+
for (const edge of vaultEdges) {
|
|
539
|
+
if (!outgoingBy.has(edge.source)) outgoingBy.set(edge.source, [])
|
|
540
|
+
outgoingBy.get(edge.source).push(edge)
|
|
541
|
+
if (!incomingBy.has(edge.target)) incomingBy.set(edge.target, [])
|
|
542
|
+
incomingBy.get(edge.target).push(edge)
|
|
543
|
+
}
|
|
544
|
+
for (const edge of outsideEdges) {
|
|
545
|
+
// An outside edge has exactly one endpoint in the vault; the other endpoint's count is never read.
|
|
546
|
+
outsideCountBy.set(edge.source, (outsideCountBy.get(edge.source) ?? 0) + 1)
|
|
547
|
+
outsideCountBy.set(edge.target, (outsideCountBy.get(edge.target) ?? 0) + 1)
|
|
548
|
+
}
|
|
549
|
+
const files = []
|
|
550
|
+
const notes = []
|
|
551
|
+
const attachments = []
|
|
552
|
+
const inversionsByEdge = new Map()
|
|
553
|
+
let fenceClosed = false
|
|
554
|
+
const reusable = isPreparationCache(cache) ? cache.notes : null
|
|
555
|
+
const nextCache = reusable ? new Map() : null
|
|
556
|
+
const preparation = { emitted: 0, reused: 0 }
|
|
557
|
+
const titles = titleRenderings()
|
|
558
|
+
|
|
559
|
+
for (const node of orderedNodes) {
|
|
560
|
+
const notePathValue = pathOf(node)
|
|
561
|
+
const pinned = read.pinned(node.repo, node.path)
|
|
562
|
+
const outgoing = (outgoingBy.get(node.id) ?? []).slice().sort((left, right) => compare(left.target, right.target))
|
|
563
|
+
const incoming = (incomingBy.get(node.id) ?? []).slice().sort((left, right) => compare(left.source, right.source))
|
|
564
|
+
const rows = relationRows({ node, outgoing, incoming, vaultNode, pathOf, titles })
|
|
565
|
+
const outsideCount = outsideCountBy.get(node.id) ?? 0
|
|
566
|
+
const occurrences = occurrencesBySource.get(node.id) ?? []
|
|
567
|
+
const key = reusable ? dependencyKey({ node, notePathValue, pinned, rows, outsideCount, occurrences, emittedTarget }) : null
|
|
568
|
+
const cached = reusable?.get(notePathValue)
|
|
569
|
+
let entry
|
|
570
|
+
if (cached && cached.key === key) {
|
|
571
|
+
entry = cached
|
|
572
|
+
preparation.reused += 1
|
|
573
|
+
} else {
|
|
574
|
+
entry = emitNote({ node, notePathValue, source: read(node.repo, node.path), rows, outsideCount, occurrences, emittedTarget, key })
|
|
575
|
+
preparation.emitted += 1
|
|
576
|
+
}
|
|
577
|
+
if (nextCache) nextCache.set(notePathValue, entry)
|
|
578
|
+
// The result never aliases the cache: a caller may change what it was handed, bytes included.
|
|
579
|
+
for (const file of entry.files) files.push({ ...file, bytes: Buffer.from(file.bytes) })
|
|
580
|
+
if (entry.attachment) attachments.push(structuredClone(entry.attachment))
|
|
581
|
+
notes.push(structuredClone(entry.note))
|
|
582
|
+
for (const [edgeKey, inversions] of entry.edgeInversions) {
|
|
583
|
+
const inversionKey = `${node.id}\u0000${edgeKey}`
|
|
584
|
+
if (!inversionsByEdge.has(inversionKey)) inversionsByEdge.set(inversionKey, [])
|
|
585
|
+
inversionsByEdge.get(inversionKey).push(...structuredClone(inversions))
|
|
586
|
+
}
|
|
587
|
+
if (entry.fenceClosed) fenceClosed = true
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// One copy per asset, however many notes embed it, read through the pinned
|
|
591
|
+
// snapshot like every other source.
|
|
592
|
+
for (const asset of embeddedAssets.values()) {
|
|
593
|
+
const attachmentPath = assetPaths.get(asset.id)
|
|
594
|
+
const { bytes, rawDigest } = read(asset.repo, asset.path)
|
|
595
|
+
attachments.push({ path: attachmentPath, digest: rawDigest, byteLength: bytes.length, ext: { [EXT_KEY]: { kind: 'embedded-asset', repoId: asset.repo, assetPath: asset.path } } })
|
|
596
|
+
files.push({ path: attachmentPath, kind: 'attachment', bytes, digest: rawDigest })
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const links = vaultEdges
|
|
600
|
+
.map((edge) => {
|
|
601
|
+
const inversions = edge.type === DERIVED_RELATION_TYPE ? inversionsByEdge.get(`${edge.source}\u0000${edge.target}`) ?? [] : []
|
|
602
|
+
return {
|
|
603
|
+
edgeId: edgeIdentifier(edge),
|
|
604
|
+
sourceNodeId: edge.source,
|
|
605
|
+
targetNodeId: edge.target,
|
|
606
|
+
type: edge.type,
|
|
607
|
+
origin: edge.type === DERIVED_RELATION_TYPE ? 'markdown' : 'declared',
|
|
608
|
+
targetState: 'in-scope',
|
|
609
|
+
...(inversions.length > 0 ? { inversions } : {}),
|
|
610
|
+
}
|
|
611
|
+
})
|
|
612
|
+
.sort((left, right) => compare(left.edgeId, right.edgeId))
|
|
613
|
+
|
|
614
|
+
const settings = prepareSettings({ existing: existingSettings })
|
|
615
|
+
files.push(...settings.files)
|
|
616
|
+
const occupied = new Set()
|
|
617
|
+
for (const file of files) {
|
|
618
|
+
if (occupied.has(collisionKey(file.path))) refuse('path-collision', 'two prepared files would occupy one path')
|
|
619
|
+
occupied.add(collisionKey(file.path))
|
|
620
|
+
assertContained(file.path)
|
|
621
|
+
if (isUserOwnedSettingsPath(file.path)) refuse('user-owned-settings', 'a prepared view may not carry a settings file the person owns')
|
|
622
|
+
}
|
|
623
|
+
files.sort((left, right) => compare(left.path, right.path))
|
|
624
|
+
attachments.sort((left, right) => compare(left.path, right.path))
|
|
625
|
+
|
|
626
|
+
const contentDigest = createHash('sha256')
|
|
627
|
+
for (const file of files) contentDigest.update(`${file.path}\u0000${file.digest}\n`)
|
|
628
|
+
const manifest = {
|
|
629
|
+
schema: 'atelier-obsidian-generation-manifest/v1',
|
|
630
|
+
contractVersion: '1.0.0',
|
|
631
|
+
generationId: generationId ?? `gen-${contentDigest.digest('hex').slice(0, 32)}`,
|
|
632
|
+
scopeId: scope.scopeId,
|
|
633
|
+
snapshotId: snapshot.document.snapshotId,
|
|
634
|
+
notes,
|
|
635
|
+
links,
|
|
636
|
+
attachments,
|
|
637
|
+
completeness: { status: 'complete', expectedNotes: orderedNodes.length, writtenNotes: notes.length },
|
|
638
|
+
freshness: { status: 'current', checkedAt },
|
|
639
|
+
ext: { [EXT_KEY]: { emitterVersion: EMITTER_VERSION, mode: selection.mode, settings: settings.ownership } },
|
|
640
|
+
}
|
|
641
|
+
assertObsidianContract('generation-manifest', manifest)
|
|
642
|
+
const vaultSuffixes = new Set([...orderedNodes, ...embeddedAssets.values()].map((item) => identitySuffix(item.repo, item.id, 64)))
|
|
643
|
+
assertOnlyVaultIdentities({
|
|
644
|
+
manifest,
|
|
645
|
+
files,
|
|
646
|
+
vaultSuffixes,
|
|
647
|
+
// Every census identity that is not part of this view: withheld, out of
|
|
648
|
+
// the selection, or an asset this view does not copy.
|
|
649
|
+
forbiddenSuffixes: new Set([
|
|
650
|
+
// A record without a usable identity is not in any view and has no
|
|
651
|
+
// suffix to forbid; it must not abort the view either.
|
|
652
|
+
...[...canonical.nodes, ...(Array.isArray(snapshot.graph.assets) ? snapshot.graph.assets : [])]
|
|
653
|
+
.filter((item) => item && typeof item.repo === 'string' && item.repo !== '' && typeof item.id === 'string' && item.id !== '')
|
|
654
|
+
.map((item) => identitySuffix(item.repo, item.id, 64)),
|
|
655
|
+
].filter((suffix) => !vaultSuffixes.has(suffix))),
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
const prior = new Map((priorManifest?.notes ?? []).map((note) => [note.path, note.noteDigest]))
|
|
659
|
+
const changes = { added: [], changed: [], unchanged: [], removed: [] }
|
|
660
|
+
for (const note of notes) changes[!prior.has(note.path) ? 'added' : prior.get(note.path) === note.noteDigest ? 'unchanged' : 'changed'].push(note.path)
|
|
661
|
+
const present = new Set(notes.map((note) => note.path))
|
|
662
|
+
// Removal is reported, never performed: an editable note leaves a vault only
|
|
663
|
+
// under the publication protocol.
|
|
664
|
+
changes.removed = [...prior.keys()].filter((notePathValue) => !present.has(notePathValue)).sort(compare)
|
|
665
|
+
|
|
666
|
+
// The cache is replaced only by a preparation that passed every check, and
|
|
667
|
+
// holds exactly the notes of this view.
|
|
668
|
+
if (nextCache) cache.notes = nextCache
|
|
669
|
+
|
|
670
|
+
return {
|
|
671
|
+
manifest,
|
|
672
|
+
manifestBytes: utf8(`${JSON.stringify(manifest, null, 2)}\n`),
|
|
673
|
+
files,
|
|
674
|
+
persistentPathRegistry: registry,
|
|
675
|
+
changes,
|
|
676
|
+
// How many notes were emitted on this call and how many were reused from the cache; without a cache every note is emitted.
|
|
677
|
+
preparation,
|
|
678
|
+
diagnostics: [
|
|
679
|
+
...selection.diagnostics,
|
|
680
|
+
...(canonical.externalEdgeCount > 0 ? ['relations-to-identities-outside-the-census-are-not-emitted'] : []),
|
|
681
|
+
...(fenceClosed ? ['unclosed-code-fence-closed-in-generated-region'] : []),
|
|
682
|
+
],
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Last check before anything is returned. Two rules, because the two kinds of
|
|
687
|
+
// bytes differ: a substring the emitter itself produced from an allocated path
|
|
688
|
+
// (note and attachment paths, rewritten link targets) may name only a note or
|
|
689
|
+
// asset of this view; free text in a generated region (titles, tags, source
|
|
690
|
+
// paths, which are the author's words) is refused only when it names an
|
|
691
|
+
// identity of the census that is outside this view. Author text that merely
|
|
692
|
+
// looks like an identity suffix, such as a content-hashed asset name, is not
|
|
693
|
+
// a redaction failure.
|
|
694
|
+
function assertOnlyVaultIdentities({ manifest, files, vaultSuffixes, forbiddenSuffixes }) {
|
|
695
|
+
const index = (suffixes) => {
|
|
696
|
+
const byPrefix = new Map()
|
|
697
|
+
for (const full of suffixes) byPrefix.set(full.slice(0, 12), [...(byPrefix.get(full.slice(0, 12)) ?? []), full])
|
|
698
|
+
return (suffix) => (byPrefix.get(suffix.slice(0, 12)) ?? []).some((full) => full.startsWith(suffix))
|
|
699
|
+
}
|
|
700
|
+
const allowed = index(vaultSuffixes)
|
|
701
|
+
const forbidden = index(forbiddenSuffixes)
|
|
702
|
+
const suffixesIn = (text) => [...text.matchAll(/--([0-9a-f]{12,64})(?![0-9a-f])/g)].map((match) => match[1])
|
|
703
|
+
// An emitted path ends in the allocated suffix (before the extension); the
|
|
704
|
+
// readable part before it is the author's title and may look like anything.
|
|
705
|
+
// A wikilink rewrite may append `|<the author's own words>`; only the part
|
|
706
|
+
// before the first `|` is an emitted path.
|
|
707
|
+
const checkEmitted = (text) => {
|
|
708
|
+
for (const segment of text.split('|')[0].split('#')[0].split('/')) {
|
|
709
|
+
const trailing = /--([0-9a-f]{12,64})(?:\.[^./]+)?$/.exec(segment)
|
|
710
|
+
if (trailing && !allowed(trailing[1])) refuse('redaction-failure', 'an emitted path names a note that is not part of this view')
|
|
711
|
+
}
|
|
712
|
+
for (const suffix of suffixesIn(text)) if (forbidden(suffix)) refuse('redaction-failure', 'an emitted path names a note that is not part of this view')
|
|
713
|
+
}
|
|
714
|
+
const checkText = (text) => {
|
|
715
|
+
for (const suffix of suffixesIn(text)) if (forbidden(suffix)) refuse('redaction-failure', 'generated output names a note that is not part of this view')
|
|
716
|
+
}
|
|
717
|
+
const byPath = new Map(files.map((file) => [file.path, file.bytes]))
|
|
718
|
+
for (const note of manifest.notes) {
|
|
719
|
+
const bytes = byPath.get(note.path)
|
|
720
|
+
for (const region of note.regions.generated) checkText(bytes.subarray(region.range.start, region.range.end).toString('utf8'))
|
|
721
|
+
}
|
|
722
|
+
for (const link of manifest.links) {
|
|
723
|
+
for (const inversion of link.inversions ?? []) checkEmitted(Buffer.from(inversion.ext[EXT_KEY].emitted, 'base64url').toString('utf8'))
|
|
724
|
+
}
|
|
725
|
+
for (const note of manifest.notes) {
|
|
726
|
+
for (const embed of note.ext[EXT_KEY].assetEmbeds ?? []) {
|
|
727
|
+
checkEmitted(embed.attachment)
|
|
728
|
+
for (const inversion of embed.inversions) checkEmitted(Buffer.from(inversion.ext[EXT_KEY].emitted, 'base64url').toString('utf8'))
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
for (const note of manifest.notes) checkEmitted(note.path)
|
|
732
|
+
for (const attachment of manifest.attachments) checkEmitted(attachment.path)
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Adds the fail-closed eligibility flag selectScope reads. A node is eligible
|
|
736
|
+
// only when `isEligible` returns exactly true for it. Embedded assets follow
|
|
737
|
+
// the same rule through `isAssetEligible`; without it every asset is withheld.
|
|
738
|
+
export function withEligibility(graph, isEligible, isAssetEligible = null) {
|
|
739
|
+
return {
|
|
740
|
+
...graph,
|
|
741
|
+
nodes: graph.nodes.map((node) => ({ ...node, eligible: isEligible(node) === true })),
|
|
742
|
+
...(Array.isArray(graph.assets)
|
|
743
|
+
? { assets: graph.assets.map((asset) => ({ ...asset, eligible: typeof isAssetEligible === 'function' && isAssetEligible(asset) === true })) }
|
|
744
|
+
: {}),
|
|
745
|
+
}
|
|
746
|
+
}
|