@vellumai/assistant 0.8.8 → 0.8.9-staging.2
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/ARCHITECTURE.md +6 -6
- package/bun.lock +2 -2
- package/examples/plugins/echo/README.md +61 -60
- package/examples/plugins/echo/hooks/post-tool-use.ts +18 -0
- package/examples/plugins/echo/hooks/stop.ts +16 -0
- package/examples/plugins/echo/hooks/user-prompt-submit.ts +18 -0
- package/examples/plugins/echo/package.json +1 -2
- package/examples/plugins/echo/src/emit.ts +19 -0
- package/node_modules/@vellumai/skill-host-contracts/src/skill-host.ts +7 -6
- package/openapi.yaml +235 -6
- package/package.json +2 -2
- package/src/__tests__/agent-loop-callsite-precedence.test.ts +69 -14
- package/src/__tests__/agent-loop-exit-reason.test.ts +204 -144
- package/src/__tests__/agent-loop-mutable-latest-user-message.test.ts +50 -35
- package/src/__tests__/agent-loop-output-hooks.test.ts +357 -0
- package/src/__tests__/agent-loop-override-profile.test.ts +25 -6
- package/src/__tests__/agent-loop-provider-error-recording.test.ts +41 -21
- package/src/__tests__/agent-loop-thinking.test.ts +36 -20
- package/src/__tests__/agent-loop.test.ts +441 -96
- package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +14 -14
- package/src/__tests__/agent-wake-override-profile.test.ts +17 -21
- package/src/__tests__/anthropic-provider.test.ts +1 -1
- package/src/__tests__/app-builder-tool-scripts.test.ts +21 -0
- package/src/__tests__/app-control-flow.test.ts +1 -1
- package/src/__tests__/app-dir-path-guard.test.ts +1 -0
- package/src/__tests__/app-executors.test.ts +132 -0
- package/src/__tests__/approval-cascade.test.ts +5 -4
- package/src/__tests__/approval-routes-http.test.ts +4 -1
- package/src/__tests__/background-workers-disk-pressure.test.ts +1 -1
- package/src/__tests__/channel-approval-routes.test.ts +1 -1
- package/src/__tests__/channel-approvals.test.ts +1 -1
- package/src/__tests__/compaction-circuit.test.ts +258 -0
- package/src/__tests__/compaction-direct.test.ts +132 -0
- package/src/__tests__/compaction-events.test.ts +5 -5
- package/src/__tests__/compactor-web-search-strip.test.ts +213 -0
- package/src/__tests__/context-overflow-reducer.test.ts +1 -1
- package/src/__tests__/conversation-abort-tool-results.test.ts +6 -4
- package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +7 -10
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +78 -119
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +142 -218
- package/src/__tests__/conversation-agent-loop.test.ts +297 -586
- package/src/__tests__/conversation-clean-command.test.ts +5 -2
- package/src/__tests__/conversation-confirmation-signals.test.ts +5 -4
- package/src/__tests__/conversation-crud-inference-profile.test.ts +7 -9
- package/src/__tests__/conversation-history-web-search.test.ts +1 -1
- package/src/__tests__/conversation-process-app-control-preactivation.test.ts +4 -4
- package/src/__tests__/conversation-process-callsite.test.ts +14 -14
- package/src/__tests__/conversation-provider-retry-repair.test.ts +70 -65
- package/src/__tests__/conversation-queue.test.ts +9 -9
- package/src/__tests__/conversation-runtime-assembly.test.ts +923 -231
- package/src/__tests__/conversation-runtime-workspace.test.ts +115 -20
- package/src/__tests__/conversation-slash-queue.test.ts +6 -4
- package/src/__tests__/conversation-slash-unknown.test.ts +6 -4
- package/src/__tests__/conversation-speed-override.test.ts +10 -9
- package/src/__tests__/conversation-starter-routes.test.ts +14 -6
- package/src/__tests__/conversation-workspace-cache-state.test.ts +23 -20
- package/src/__tests__/conversation-workspace-injection.test.ts +68 -6
- package/src/__tests__/conversation-workspace-tool-tracking.test.ts +14 -11
- package/src/__tests__/conversations-import-system-filter.test.ts +101 -0
- package/src/__tests__/credential-security-invariants.test.ts +0 -1
- package/src/__tests__/db-acp-history.test.ts +101 -0
- package/src/__tests__/dynamic-page-surface.test.ts +31 -0
- package/src/__tests__/empty-response-hook.test.ts +1 -1
- package/src/__tests__/file-write-tool.test.ts +63 -0
- package/src/__tests__/gateway-only-guard.test.ts +12 -2
- package/src/__tests__/guardian-grant-minting.test.ts +1 -1
- package/src/__tests__/guardian-routing-invariants.test.ts +2 -4
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +1 -1
- package/src/__tests__/heartbeat-disk-pressure.test.ts +1 -0
- package/src/__tests__/heartbeat-service.test.ts +1 -0
- package/src/__tests__/history-repair-hook.test.ts +1 -1
- package/src/__tests__/host-app-control-routes.test.ts +1 -1
- package/src/__tests__/host-cu-routes-targeted.test.ts +3 -3
- package/src/__tests__/inference-profile-reaper.test.ts +62 -0
- package/src/__tests__/inference-profile-session-handler.test.ts +86 -0
- package/src/__tests__/injector-background-turn.test.ts +13 -23
- package/src/__tests__/injector-chain.test.ts +268 -44
- package/src/__tests__/injector-disk-pressure.test.ts +210 -52
- package/src/__tests__/injector-document-comments.test.ts +97 -114
- package/src/__tests__/injector-pkb-v2-silenced.test.ts +2 -2
- package/src/__tests__/injector-v3-suppression.test.ts +4 -4
- package/src/__tests__/list-messages-client-message-id.test.ts +91 -0
- package/src/__tests__/list-messages-hidden-metadata.test.ts +38 -0
- package/src/__tests__/memory-retrieval-hook.test.ts +131 -26
- package/src/__tests__/memory-v2-static-injector.test.ts +86 -8
- package/src/__tests__/parallel-tool.benchmark.test.ts +35 -8
- package/src/__tests__/plugin-api-shim.test.ts +6 -9
- package/src/__tests__/plugin-bootstrap.test.ts +12 -23
- package/src/__tests__/plugin-registry.test.ts +3 -49
- package/src/__tests__/plugin-types.test.ts +0 -70
- package/src/__tests__/pre-model-call-sanitize.test.ts +109 -0
- package/src/__tests__/reaction-persistence.test.ts +1 -1
- package/src/__tests__/send-endpoint-busy.test.ts +4 -1
- package/src/__tests__/skill-feature-flags-integration.test.ts +33 -0
- package/src/__tests__/steer-tool-repair.test.ts +1 -1
- package/src/__tests__/subagent-call-site-routing.test.ts +1 -1
- package/src/__tests__/subagent-detail.test.ts +25 -7
- package/src/__tests__/subagent-fork-notifications.test.ts +1 -3
- package/src/__tests__/subagent-fork-spawn.test.ts +1 -1
- package/src/__tests__/subagent-manager-notify.test.ts +1 -3
- package/src/__tests__/subagent-notify-parent.test.ts +1 -3
- package/src/__tests__/subagent-spawn-tool-fork.test.ts +1 -1
- package/src/__tests__/title-generate-hook.test.ts +1 -1
- package/src/__tests__/tool-error-hook.test.ts +1 -1
- package/src/__tests__/tool-result-truncate-hook.test.ts +1 -1
- package/src/__tests__/user-plugin-loader.test.ts +54 -286
- package/src/acp/__tests__/agent-process.test.ts +161 -0
- package/src/acp/__tests__/client-handler.test.ts +40 -0
- package/src/acp/__tests__/helpers/acp-history-db.ts +82 -0
- package/src/acp/__tests__/helpers/exec-file-stub.ts +106 -0
- package/src/acp/__tests__/prepare-agent-env.test.ts +97 -0
- package/src/acp/__tests__/session-manager-persistence.test.ts +95 -28
- package/src/acp/__tests__/session-manager-resume.test.ts +888 -0
- package/src/acp/agent-process.ts +61 -1
- package/src/acp/auto-install.test.ts +280 -0
- package/src/acp/auto-install.ts +232 -0
- package/src/acp/client-handler.ts +31 -0
- package/src/acp/feature-gate.test.ts +48 -0
- package/src/acp/feature-gate.ts +34 -0
- package/src/acp/prepare-agent-env.ts +80 -27
- package/src/acp/resolve-agent.test.ts +225 -9
- package/src/acp/resolve-agent.ts +122 -17
- package/src/acp/resume-hint.ts +23 -0
- package/src/acp/session-manager.ts +507 -73
- package/src/agent/compaction-circuit.ts +60 -102
- package/src/agent/loop.ts +414 -248
- package/src/api/responses/conversation-message.ts +14 -1
- package/src/approvals/guardian-request-resolvers.ts +1 -1
- package/src/background-wake/next-wake.ts +1 -0
- package/src/cli/commands/db/__tests__/repair.test.ts +3 -1
- package/src/cli/commands/plugins.ts +43 -37
- package/src/cli/lib/__tests__/install-from-github.test.ts +429 -111
- package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +196 -0
- package/src/cli/lib/__tests__/plugin-details.test.ts +372 -0
- package/src/cli/lib/__tests__/plugin-marketplace.test.ts +220 -0
- package/src/cli/lib/__tests__/search-plugins.test.ts +226 -32
- package/src/cli/lib/install-from-github.ts +464 -55
- package/src/cli/lib/plugin-catalog-cache.ts +84 -0
- package/src/cli/lib/plugin-details.ts +409 -0
- package/src/cli/lib/plugin-marketplace.ts +197 -0
- package/src/cli/lib/search-plugins.ts +195 -29
- package/src/config/__tests__/feature-flag-registry-guard.test.ts +2 -2
- package/src/config/acp-defaults.test.ts +10 -0
- package/src/config/acp-defaults.ts +6 -0
- package/src/config/bundled-skills/acp/SKILL.md +85 -33
- package/src/config/bundled-skills/acp/TOOLS.json +4 -4
- package/src/config/bundled-skills/app-builder/SKILL.md +224 -381
- package/src/config/bundled-skills/app-builder/TOOLS.json +72 -2
- package/src/config/bundled-skills/app-builder/references/DESIGN_SYSTEM.md +48 -0
- package/src/config/bundled-skills/app-builder/references/RESPONSIVE.md +57 -0
- package/src/config/bundled-skills/app-builder/references/SLIDES.md +38 -0
- package/src/config/bundled-skills/app-builder/tools/app-list.ts +62 -0
- package/src/config/bundled-skills/app-builder/tools/app-update.ts +18 -0
- package/src/config/bundled-skills/document-editor/SKILL.md +28 -23
- package/src/config/bundled-skills/document-editor/TOOLS.json +1 -1
- package/src/config/bundled-tool-registry.ts +4 -0
- package/src/config/call-site-defaults.ts +0 -2
- package/src/config/feature-flag-registry.json +15 -6
- package/src/config/schemas/call-site-catalog.ts +0 -14
- package/src/config/schemas/heartbeat.ts +9 -0
- package/src/config/schemas/llm.ts +0 -2
- package/src/context/compactor.ts +22 -4
- package/src/context/strip-injections.ts +8 -2
- package/src/context/window-manager.ts +27 -13
- package/src/daemon/conversation-agent-loop-handlers.ts +10 -35
- package/src/daemon/conversation-agent-loop.ts +175 -1055
- package/src/daemon/conversation-lifecycle.ts +11 -255
- package/src/daemon/conversation-process.ts +8 -136
- package/src/daemon/conversation-registry.ts +159 -0
- package/src/daemon/conversation-runtime-assembly.ts +293 -392
- package/src/daemon/conversation-store.ts +9 -90
- package/src/daemon/conversation-surfaces.ts +24 -8
- package/src/daemon/conversation-workspace.ts +17 -0
- package/src/daemon/conversation.ts +404 -57
- package/src/daemon/disk-pressure-policy.ts +0 -1
- package/src/daemon/external-plugins-bootstrap.ts +14 -19
- package/src/daemon/handlers/conversations.ts +3 -1
- package/src/daemon/handlers/skills.ts +4 -1
- package/src/daemon/host-proxy-preactivation.ts +1 -3
- package/src/daemon/lifecycle.ts +21 -7
- package/src/daemon/server.ts +2 -0
- package/src/daemon/wake-conversation-ops.ts +269 -0
- package/src/embedded/plugin-api.ts +2 -2
- package/src/export/__tests__/transcript-formatter.test.ts +5 -0
- package/src/heartbeat/__tests__/heartbeat-service.test.ts +3 -0
- package/src/heartbeat/heartbeat-run-store.ts +23 -1
- package/src/heartbeat/heartbeat-service.ts +26 -0
- package/src/ipc/__tests__/browser-ipc.test.ts +1 -1
- package/src/ipc/__tests__/ui-request-route.test.ts +3 -3
- package/src/ipc/skill-routes/__tests__/memory.test.ts +15 -0
- package/src/ipc/skill-routes/memory.ts +4 -2
- package/src/memory/__tests__/jobs-worker-v2-schedule.test.ts +87 -0
- package/src/memory/conversation-crud.ts +29 -19
- package/src/memory/conversation-starter-checkpoints.ts +1 -0
- package/src/memory/db-init.ts +2 -0
- package/src/memory/job-handlers/conversation-starters.ts +13 -2
- package/src/memory/jobs/__tests__/embed-concept-page.test.ts +5 -4
- package/src/memory/jobs-worker.ts +25 -1
- package/src/memory/migrations/272-acp-session-history-cwd.ts +36 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/schema/acp.ts +4 -0
- package/src/memory/v2/__tests__/consolidation-job.test.ts +3 -3
- package/src/memory/v2/consolidation-job.ts +13 -4
- package/src/plugin-api/constants.ts +4 -0
- package/src/plugin-api/index.ts +6 -5
- package/src/plugin-api/types.ts +75 -0
- package/src/plugins/defaults/compaction/compact.ts +59 -0
- package/src/{daemon → plugins/defaults/compaction}/context-overflow-reducer.ts +7 -7
- package/src/plugins/defaults/compaction/manager-store.ts +57 -0
- package/src/plugins/defaults/compaction/package.json +1 -2
- package/src/plugins/defaults/empty-response/package.json +0 -1
- package/src/plugins/defaults/history-repair/package.json +0 -1
- package/src/plugins/defaults/index.ts +135 -26
- package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +100 -52
- package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +184 -74
- package/src/plugins/defaults/memory-retrieval/injector-chain.ts +2 -2
- package/src/plugins/defaults/{injectors/register.ts → memory-retrieval/injectors.ts} +148 -73
- package/src/plugins/defaults/memory-retrieval/unified-turn-context.ts +223 -0
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/assign.test.ts +4 -4
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/live-integration.test.ts +9 -6
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/maintain-job.test.ts +5 -5
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/orchestrate.test.ts +8 -5
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/reconcile.test.ts +2 -2
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/render-injection.test.ts +1 -1
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/router.test.ts +10 -5
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/selection-log-store.test.ts +8 -8
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/selector.test.ts +5 -5
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/shadow-plugin.test.ts +16 -17
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/types.test.ts +2 -2
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/assign.ts +9 -5
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/capabilities.ts +5 -2
- package/src/plugins/defaults/memory-v3-shadow/hooks/post-compact.ts +14 -0
- package/src/plugins/defaults/memory-v3-shadow/hooks/user-prompt-submit.ts +19 -0
- package/src/plugins/defaults/memory-v3-shadow/injector.ts +75 -0
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/maintain-job.ts +15 -8
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/orchestrate.ts +2 -2
- package/src/plugins/defaults/memory-v3-shadow/package.json +14 -0
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/page-content.ts +2 -2
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/provider-blocks.ts +1 -1
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/reconcile.ts +7 -3
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/render-injection.ts +1 -1
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/router.ts +5 -5
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/selection-log-store.ts +4 -4
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/selector.ts +7 -7
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/shadow-plugin.ts +32 -94
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/tree.ts +1 -1
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/types.ts +1 -1
- package/src/plugins/defaults/title-generate/package.json +0 -1
- package/src/plugins/defaults/tool-error/package.json +0 -1
- package/src/plugins/defaults/tool-result-truncate/package.json +0 -1
- package/src/plugins/pipeline.ts +6 -293
- package/src/plugins/registry.ts +9 -37
- package/src/plugins/types.ts +76 -381
- package/src/plugins/user-loader.ts +30 -127
- package/src/prompts/__tests__/system-prompt.test.ts +6 -0
- package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +35 -3
- package/src/runtime/__tests__/agent-wake.test.ts +555 -691
- package/src/runtime/__tests__/interactive-ui.test.ts +1 -1
- package/src/runtime/agent-wake.ts +108 -209
- package/src/runtime/assistant-event-hub.ts +1 -1
- package/src/runtime/channel-approvals.ts +1 -1
- package/src/runtime/interactive-ui.ts +1 -1
- package/src/runtime/routes/__tests__/acp-routes.test.ts +315 -55
- package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +1 -1
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +423 -73
- package/src/runtime/routes/__tests__/surface-action-routes.test.ts +5 -4
- package/src/runtime/routes/__tests__/surface-content-routes.test.ts +4 -1
- package/src/runtime/routes/acp-routes.test.ts +89 -25
- package/src/runtime/routes/acp-routes.ts +81 -29
- package/src/runtime/routes/approval-routes.ts +1 -1
- package/src/runtime/routes/browser-routes.ts +1 -1
- package/src/runtime/routes/browser-tabs-routes.ts +6 -10
- package/src/runtime/routes/conversation-cli-routes.ts +1 -1
- package/src/runtime/routes/conversation-list-routes.ts +1 -1
- package/src/runtime/routes/conversation-query-routes.ts +1 -1
- package/src/runtime/routes/conversation-routes.ts +28 -2
- package/src/runtime/routes/conversation-starter-routes.ts +13 -7
- package/src/runtime/routes/conversations-import-routes.ts +24 -7
- package/src/runtime/routes/host-app-control-routes.ts +1 -1
- package/src/runtime/routes/host-cu-routes.ts +1 -1
- package/src/runtime/routes/identity-routes.ts +18 -3
- package/src/runtime/routes/inbound-message-handler.ts +1 -1
- package/src/runtime/routes/inference-profile-session-handler.ts +11 -0
- package/src/runtime/routes/inference-profile-session-reaper.ts +6 -0
- package/src/runtime/routes/memory-v3-routes.ts +16 -6
- package/src/runtime/routes/playground/helpers.ts +1 -1
- package/src/runtime/routes/plugins-routes.ts +337 -35
- package/src/runtime/routes/surface-conversation-resolver.ts +4 -3
- package/src/runtime/routes/work-items-routes.ts +2 -4
- package/src/runtime/services/conversation-serializer.ts +1 -1
- package/src/signals/cancel.ts +2 -4
- package/src/subagent/manager.ts +21 -5
- package/src/tools/acp/context.ts +20 -0
- package/src/tools/acp/list-agents.test.ts +8 -2
- package/src/tools/acp/spawn.test.ts +176 -195
- package/src/tools/acp/spawn.ts +37 -172
- package/src/tools/acp/steer.test.ts +105 -8
- package/src/tools/acp/steer.ts +48 -17
- package/src/tools/apps/executors.ts +166 -50
- package/src/tools/filesystem/write.ts +34 -0
- package/src/tools/subagent/spawn.ts +2 -4
- package/src/tools/ui-surface/definitions.ts +25 -5
- package/src/workspace/migrations/051-seed-conversation-summarization-callsite.ts +4 -5
- package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +69 -45
- package/docs/plugins.md +0 -832
- package/examples/plugins/echo/register.ts +0 -143
- package/src/__tests__/circuit-breaker-pipeline.test.ts +0 -405
- package/src/__tests__/compaction-pipeline.test.ts +0 -210
- package/src/__tests__/compaction-timeout-recovery.test.ts +0 -251
- package/src/__tests__/overflow-reduce-pipeline.test.ts +0 -667
- package/src/__tests__/pipeline-runner.test.ts +0 -554
- package/src/__tests__/plugin-external-api.test.ts +0 -68
- package/src/daemon/wake-target-adapter.ts +0 -253
- package/src/plugins/defaults/circuit-breaker/middlewares/circuitBreaker.ts +0 -93
- package/src/plugins/defaults/circuit-breaker/package.json +0 -15
- package/src/plugins/defaults/circuit-breaker/register.ts +0 -39
- package/src/plugins/defaults/compaction/middlewares/compaction.ts +0 -25
- package/src/plugins/defaults/compaction/register.ts +0 -35
- package/src/plugins/defaults/compaction/terminal.ts +0 -73
- package/src/plugins/defaults/empty-response/register.ts +0 -23
- package/src/plugins/defaults/history-repair/register.ts +0 -24
- package/src/plugins/defaults/overflow-reduce/middlewares/overflowReduce.ts +0 -126
- package/src/plugins/defaults/overflow-reduce/package.json +0 -15
- package/src/plugins/defaults/overflow-reduce/register.ts +0 -42
- package/src/plugins/defaults/title-generate/register.ts +0 -35
- package/src/plugins/defaults/tool-error/register.ts +0 -23
- package/src/plugins/defaults/tool-result-truncate/register.ts +0 -24
- package/src/plugins/external-api.ts +0 -104
- package/src/proactive-artifact/aux-message-injector.ts +0 -97
- package/src/proactive-artifact/decision.test.ts +0 -226
- package/src/proactive-artifact/decision.ts +0 -165
- package/src/proactive-artifact/index.ts +0 -7
- package/src/proactive-artifact/job.test.ts +0 -962
- package/src/proactive-artifact/job.ts +0 -372
- package/src/proactive-artifact/message-copy.ts +0 -58
- package/src/proactive-artifact/trigger-state.test.ts +0 -286
- package/src/proactive-artifact/trigger-state.ts +0 -123
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/capabilities.test.ts +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/core.test.ts +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/fixtures/eval-turns.json +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/fixtures/live-turns.json +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/health.test.ts +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/needle.test.ts +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/provider-blocks.test.ts +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/snapshot.test.ts +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/tree.test.ts +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/working-set-eviction.test.ts +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/working-set-skeleton.test.ts +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/core.ts +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/README.md +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/assignments.json +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/core.json +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/leaves/domain-a/topic-x.md +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/leaves/domain-a/topic-y.md +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/leaves/domain-b/topic-z.md +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/health.ts +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/llm-retry.ts +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/needle.ts +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/snapshot.ts +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/working-set.ts +0 -0
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Install
|
|
2
|
+
* Install a plugin by name from the canonical GitHub source.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* A name resolves to one of two sources, materialized into
|
|
5
|
+
* `<workspacePluginsDir>/<name>/` so the daemon discovers it on next start:
|
|
6
|
+
* 1. A whitelisted external ecosystem plugin, when the name matches an entry
|
|
7
|
+
* in the curated `experimental/plugins/marketplace.json` manifest. The
|
|
8
|
+
* pinned `owner/repo[/path]@ref` (see {@link ./plugin-marketplace}) is
|
|
9
|
+
* fetched with a shallow `git` clone at that ref — one network operation
|
|
10
|
+
* regardless of repo size, immune to GitHub's unauthenticated API
|
|
11
|
+
* rate-limit, and recording the exact resolved commit for provenance.
|
|
12
|
+
* 2. Otherwise the first-party convention
|
|
13
|
+
* `vellum-ai/vellum-assistant/experimental/plugins/<name>/` at the
|
|
14
|
+
* configured ref, fetched via the GitHub Contents API (a small handful of
|
|
15
|
+
* in-repo files — cloning the whole monorepo to install one would be
|
|
16
|
+
* wasteful).
|
|
9
17
|
*
|
|
10
18
|
* Designed for direct programmatic use. The CLI command
|
|
11
19
|
* `assistant plugins install <name>` is a thin wrapper that supplies
|
|
@@ -15,10 +23,27 @@
|
|
|
15
23
|
* a test fixture) and an override workspace directory.
|
|
16
24
|
*/
|
|
17
25
|
|
|
18
|
-
import {
|
|
26
|
+
import { execFile } from "node:child_process";
|
|
27
|
+
import {
|
|
28
|
+
copyFileSync,
|
|
29
|
+
existsSync,
|
|
30
|
+
mkdirSync,
|
|
31
|
+
readdirSync,
|
|
32
|
+
renameSync,
|
|
33
|
+
rmSync,
|
|
34
|
+
statSync,
|
|
35
|
+
writeFileSync,
|
|
36
|
+
} from "node:fs";
|
|
19
37
|
import { dirname, join } from "node:path";
|
|
38
|
+
import { promisify } from "node:util";
|
|
20
39
|
|
|
21
40
|
import { getWorkspacePluginsDir } from "../../util/platform.js";
|
|
41
|
+
import {
|
|
42
|
+
fetchMarketplaceEntries,
|
|
43
|
+
resolveMarketplaceSource,
|
|
44
|
+
} from "./plugin-marketplace.js";
|
|
45
|
+
|
|
46
|
+
const execFileAsync = promisify(execFile);
|
|
22
47
|
|
|
23
48
|
const PLUGIN_SOURCE_OWNER = "vellum-ai";
|
|
24
49
|
const PLUGIN_SOURCE_REPO = "vellum-assistant";
|
|
@@ -47,6 +72,16 @@ export type FetchLike = (
|
|
|
47
72
|
init?: RequestInit,
|
|
48
73
|
) => Promise<Response>;
|
|
49
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Runs a `git` subcommand in `cwd` and resolves its stdout. Injected so tests
|
|
77
|
+
* can simulate a clone without spawning a real git process; production callers
|
|
78
|
+
* fall back to {@link defaultGitRunner}.
|
|
79
|
+
*/
|
|
80
|
+
export type GitRunner = (
|
|
81
|
+
args: readonly string[],
|
|
82
|
+
opts: { readonly cwd: string },
|
|
83
|
+
) => Promise<{ readonly stdout: string }>;
|
|
84
|
+
|
|
50
85
|
/** Options that control which plugin to install and how. */
|
|
51
86
|
export interface InstallPluginOptions {
|
|
52
87
|
readonly name: string;
|
|
@@ -63,6 +98,8 @@ export interface InstallPluginDeps {
|
|
|
63
98
|
readonly fetch: FetchLike;
|
|
64
99
|
/** Override the workspace plugins directory. Falls back to {@link getWorkspacePluginsDir}. */
|
|
65
100
|
readonly workspacePluginsDir?: string;
|
|
101
|
+
/** Override the git runner used to clone external plugin sources. Falls back to {@link defaultGitRunner}. */
|
|
102
|
+
readonly runGit?: GitRunner;
|
|
66
103
|
}
|
|
67
104
|
|
|
68
105
|
/** Successful install result. */
|
|
@@ -72,12 +109,16 @@ export interface InstallPluginResult {
|
|
|
72
109
|
readonly target: string;
|
|
73
110
|
readonly fileCount: number;
|
|
74
111
|
readonly ref: string;
|
|
112
|
+
/** Resolved commit SHA for git-cloned external sources; null for first-party (no clone). */
|
|
113
|
+
readonly commit: string | null;
|
|
75
114
|
}
|
|
76
115
|
|
|
77
116
|
/** Plugin name failed sanitization. */
|
|
78
117
|
export class InvalidPluginNameError extends Error {
|
|
79
118
|
constructor(name: string) {
|
|
80
|
-
super(
|
|
119
|
+
super(
|
|
120
|
+
`Invalid plugin name "${name}". Names must match /^[a-z0-9][a-z0-9_-]*$/.`,
|
|
121
|
+
);
|
|
81
122
|
this.name = "InvalidPluginNameError";
|
|
82
123
|
}
|
|
83
124
|
}
|
|
@@ -98,13 +139,142 @@ export class PluginNotFoundError extends Error {
|
|
|
98
139
|
constructor(
|
|
99
140
|
readonly pluginName: string,
|
|
100
141
|
readonly ref: string,
|
|
142
|
+
/** `owner/repo/path` the plugin was looked for at. */
|
|
143
|
+
sourceLabel: string,
|
|
101
144
|
) {
|
|
102
|
-
|
|
103
|
-
super(`Plugin "${pluginName}" not found at ${sourcePath} (ref ${ref}).`);
|
|
145
|
+
super(`Plugin "${pluginName}" not found at ${sourceLabel} (ref ${ref}).`);
|
|
104
146
|
this.name = "PluginNotFoundError";
|
|
105
147
|
}
|
|
106
148
|
}
|
|
107
149
|
|
|
150
|
+
/**
|
|
151
|
+
* The plugin source is temporarily unreachable — GitHub rate-limited us or
|
|
152
|
+
* returned a 5xx. Distinct from a hard failure (the plugin genuinely doesn't
|
|
153
|
+
* exist) so the caller can surface a retryable 503 instead of a 500.
|
|
154
|
+
*/
|
|
155
|
+
export class PluginSourceUnavailableError extends Error {
|
|
156
|
+
readonly status: number;
|
|
157
|
+
constructor(message: string, status: number) {
|
|
158
|
+
super(message);
|
|
159
|
+
this.name = "PluginSourceUnavailableError";
|
|
160
|
+
this.status = status;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Classify an upstream GitHub status as transient (worth retrying) vs hard.
|
|
166
|
+
* A 429 or 5xx is always transient. A 403 is GitHub's unauthenticated
|
|
167
|
+
* rate-limit signal only when the remaining-quota header is exhausted —
|
|
168
|
+
* a 403 without it is a genuine authorization failure and stays hard.
|
|
169
|
+
*/
|
|
170
|
+
function isTransientUpstreamStatus(res: Response): boolean {
|
|
171
|
+
if (res.status === 429 || res.status >= 500) return true;
|
|
172
|
+
if (res.status === 403) {
|
|
173
|
+
return res.headers.get("x-ratelimit-remaining") === "0";
|
|
174
|
+
}
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Resolved GitHub coordinates a plugin name is fetched from. */
|
|
179
|
+
interface PluginFetchSource {
|
|
180
|
+
/** Whether the plugin lives in our monorepo or an external whitelisted repo. */
|
|
181
|
+
readonly kind: "first-party" | "external";
|
|
182
|
+
readonly owner: string;
|
|
183
|
+
readonly repo: string;
|
|
184
|
+
/** Repo-relative directory holding the plugin root; `""` = repo root. */
|
|
185
|
+
readonly rootPath: string;
|
|
186
|
+
readonly ref: string;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Build the `owner/repo/path` label used in not-found errors. */
|
|
190
|
+
function sourceLabel(source: PluginFetchSource): string {
|
|
191
|
+
return source.rootPath
|
|
192
|
+
? `${source.owner}/${source.repo}/${source.rootPath}`
|
|
193
|
+
: `${source.owner}/${source.repo}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** First-party `experimental/plugins/<name>` coordinates at a given ref. */
|
|
197
|
+
function firstPartySource(name: string, ref: string): PluginFetchSource {
|
|
198
|
+
return {
|
|
199
|
+
kind: "first-party",
|
|
200
|
+
owner: PLUGIN_SOURCE_OWNER,
|
|
201
|
+
repo: PLUGIN_SOURCE_REPO,
|
|
202
|
+
rootPath: `${PLUGIN_SOURCE_PATH_PREFIX}/${name}`,
|
|
203
|
+
ref,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Probe whether a first-party plugin directory exists at the given source.
|
|
209
|
+
*
|
|
210
|
+
* A transient listing failure resolves to `false` so a marketplace-claimed
|
|
211
|
+
* name still reaches its external source — the rare collision guarantee gives
|
|
212
|
+
* way to keeping the common external-only install path working under flaky
|
|
213
|
+
* network conditions.
|
|
214
|
+
*/
|
|
215
|
+
async function firstPartyPluginExists(
|
|
216
|
+
source: PluginFetchSource,
|
|
217
|
+
fetchFn: FetchLike,
|
|
218
|
+
): Promise<boolean> {
|
|
219
|
+
try {
|
|
220
|
+
const entries = await listDir(
|
|
221
|
+
source.owner,
|
|
222
|
+
source.repo,
|
|
223
|
+
source.rootPath,
|
|
224
|
+
source.ref,
|
|
225
|
+
fetchFn,
|
|
226
|
+
);
|
|
227
|
+
return entries !== null && entries.length > 0;
|
|
228
|
+
} catch {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Resolve a plugin name to concrete GitHub coordinates.
|
|
235
|
+
*
|
|
236
|
+
* First-party plugins win a name collision: a name claimed by the curated
|
|
237
|
+
* marketplace is fetched from its pinned external repo only when no
|
|
238
|
+
* `experimental/plugins/<name>` directory exists in-repo. This mirrors the
|
|
239
|
+
* search catalog, where an in-repo plugin suppresses a same-named marketplace
|
|
240
|
+
* entry — install must advertise and install the same source.
|
|
241
|
+
*
|
|
242
|
+
* A missing or malformed manifest degrades to first-party resolution — the
|
|
243
|
+
* whitelist is supplementary and must never block installing a first-party
|
|
244
|
+
* plugin. An external name then surfaces a clear not-found error downstream.
|
|
245
|
+
*/
|
|
246
|
+
async function resolvePluginSource(
|
|
247
|
+
name: string,
|
|
248
|
+
marketplaceRef: string,
|
|
249
|
+
fetchFn: FetchLike,
|
|
250
|
+
): Promise<PluginFetchSource> {
|
|
251
|
+
let resolved = null;
|
|
252
|
+
try {
|
|
253
|
+
const entries = await fetchMarketplaceEntries(
|
|
254
|
+
{ fetch: fetchFn },
|
|
255
|
+
{ ref: marketplaceRef },
|
|
256
|
+
);
|
|
257
|
+
resolved = resolveMarketplaceSource(name, entries);
|
|
258
|
+
} catch {
|
|
259
|
+
// Degrade to first-party resolution below.
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const firstParty = firstPartySource(name, marketplaceRef);
|
|
263
|
+
if (!resolved) return firstParty;
|
|
264
|
+
|
|
265
|
+
if (await firstPartyPluginExists(firstParty, fetchFn)) {
|
|
266
|
+
return firstParty;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
kind: "external",
|
|
271
|
+
owner: resolved.owner,
|
|
272
|
+
repo: resolved.repo,
|
|
273
|
+
rootPath: resolved.path,
|
|
274
|
+
ref: resolved.ref,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
108
278
|
/**
|
|
109
279
|
* Reject plugin names that could escape the canonical source path or the
|
|
110
280
|
* install target. The source convention is a flat namespace under
|
|
@@ -141,7 +311,9 @@ function assertSafeFilename(label: string, candidate: string): void {
|
|
|
141
311
|
candidate.includes("\0") ||
|
|
142
312
|
candidate.split(/[/\\]/).some((seg) => seg === "..")
|
|
143
313
|
) {
|
|
144
|
-
throw new Error(
|
|
314
|
+
throw new Error(
|
|
315
|
+
`Unsafe ${label} from GitHub response: ${JSON.stringify(candidate)}`,
|
|
316
|
+
);
|
|
145
317
|
}
|
|
146
318
|
}
|
|
147
319
|
|
|
@@ -158,9 +330,12 @@ export async function installPlugin(
|
|
|
158
330
|
deps: InstallPluginDeps,
|
|
159
331
|
): Promise<InstallPluginResult> {
|
|
160
332
|
const name = sanitizePluginName(opts.name);
|
|
161
|
-
const
|
|
333
|
+
const marketplaceRef = opts.ref ?? DEFAULT_PLUGIN_REF;
|
|
162
334
|
const force = opts.force ?? false;
|
|
163
335
|
|
|
336
|
+
const source = await resolvePluginSource(name, marketplaceRef, deps.fetch);
|
|
337
|
+
const ref = source.ref;
|
|
338
|
+
|
|
164
339
|
const pluginsDir = deps.workspacePluginsDir ?? getWorkspacePluginsDir();
|
|
165
340
|
const target = join(pluginsDir, name);
|
|
166
341
|
|
|
@@ -178,13 +353,26 @@ export async function installPlugin(
|
|
|
178
353
|
mkdirSync(stagingDir, { recursive: true });
|
|
179
354
|
|
|
180
355
|
let fileCount: number;
|
|
356
|
+
let commit: string | null = null;
|
|
181
357
|
try {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
358
|
+
if (source.kind === "external") {
|
|
359
|
+
const cloned = await copyExternalViaGit(
|
|
360
|
+
source,
|
|
361
|
+
stagingDir,
|
|
362
|
+
deps.runGit ?? defaultGitRunner,
|
|
363
|
+
);
|
|
364
|
+
fileCount = cloned.fileCount;
|
|
365
|
+
commit = cloned.commit;
|
|
366
|
+
} else {
|
|
367
|
+
fileCount = await copyDir(
|
|
368
|
+
source.owner,
|
|
369
|
+
source.repo,
|
|
370
|
+
source.rootPath,
|
|
371
|
+
ref,
|
|
372
|
+
stagingDir,
|
|
373
|
+
deps.fetch,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
188
376
|
} catch (err) {
|
|
189
377
|
rmSync(stagingDir, { recursive: true, force: true });
|
|
190
378
|
throw err;
|
|
@@ -192,9 +380,15 @@ export async function installPlugin(
|
|
|
192
380
|
|
|
193
381
|
if (fileCount === 0) {
|
|
194
382
|
rmSync(stagingDir, { recursive: true, force: true });
|
|
195
|
-
throw new PluginNotFoundError(name, ref);
|
|
383
|
+
throw new PluginNotFoundError(name, ref, sourceLabel(source));
|
|
196
384
|
}
|
|
197
385
|
|
|
386
|
+
// Record install provenance (source coordinates + resolved commit) as a
|
|
387
|
+
// hidden sidecar before the swap so it lands atomically with the files. The
|
|
388
|
+
// daemon loader enumerates plugin directories and reads each plugin's
|
|
389
|
+
// `package.json`, skipping dotfiles — so this never gets mistaken for code.
|
|
390
|
+
writeInstallManifest(stagingDir, name, source, ref, commit);
|
|
391
|
+
|
|
198
392
|
// Atomic-ish swap: rmSync + renameSync. On POSIX the rename itself is
|
|
199
393
|
// atomic, so the only window where the target is absent is between the
|
|
200
394
|
// rm and the rename — and at that point the staging dir is fully populated.
|
|
@@ -203,54 +397,290 @@ export async function installPlugin(
|
|
|
203
397
|
}
|
|
204
398
|
renameSync(stagingDir, target);
|
|
205
399
|
|
|
206
|
-
return { name, target, fileCount, ref };
|
|
400
|
+
return { name, target, fileCount, ref, commit };
|
|
207
401
|
}
|
|
208
402
|
|
|
403
|
+
/** Cap on any single git invocation; a shallow fetch is well under this. */
|
|
404
|
+
const GIT_TIMEOUT_MS = 120_000;
|
|
405
|
+
|
|
406
|
+
/** Install-provenance sidecar written at the plugin root. */
|
|
407
|
+
const INSTALL_MANIFEST_FILENAME = ".vellum-plugin.json";
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Materialize an external plugin by shallow-cloning its repo at the pinned ref.
|
|
411
|
+
*
|
|
412
|
+
* A single `git fetch --depth 1 <ref>` transfers the tree in one network
|
|
413
|
+
* operation regardless of how many directories the plugin spans, so it is
|
|
414
|
+
* immune to GitHub's 60/hr unauthenticated Contents-API rate limit — the
|
|
415
|
+
* failure mode a recursive per-directory walk hit on plugins like caveman
|
|
416
|
+
* (dozens of nested folders, one API request each). Cloning also resolves the
|
|
417
|
+
* exact commit the ref points at, recorded for version provenance.
|
|
418
|
+
*
|
|
419
|
+
* The clone lands in a sibling scratch dir; only the plugin root (the repo
|
|
420
|
+
* root, or `source.rootPath` within it) is copied into `destDir`, minus the
|
|
421
|
+
* `.git` metadata and any symlinks (the loader follows neither). Returns the
|
|
422
|
+
* file count and resolved commit; zero files means the ref exists but the
|
|
423
|
+
* declared sub-path doesn't, which the caller maps to not-found.
|
|
424
|
+
*/
|
|
425
|
+
async function copyExternalViaGit(
|
|
426
|
+
source: PluginFetchSource,
|
|
427
|
+
destDir: string,
|
|
428
|
+
runGit: GitRunner,
|
|
429
|
+
): Promise<{ fileCount: number; commit: string | null }> {
|
|
430
|
+
const cloneDir = `${destDir}.gitclone`;
|
|
431
|
+
rmSync(cloneDir, { recursive: true, force: true });
|
|
432
|
+
mkdirSync(cloneDir, { recursive: true });
|
|
433
|
+
|
|
434
|
+
try {
|
|
435
|
+
const repoUrl = `https://github.com/${source.owner}/${source.repo}.git`;
|
|
436
|
+
await runGit(["init", "--quiet"], { cwd: cloneDir });
|
|
437
|
+
await runGit(["remote", "add", "origin", repoUrl], { cwd: cloneDir });
|
|
438
|
+
|
|
439
|
+
try {
|
|
440
|
+
await runGit(["fetch", "--depth", "1", "--quiet", "origin", source.ref], {
|
|
441
|
+
cwd: cloneDir,
|
|
442
|
+
});
|
|
443
|
+
} catch (err) {
|
|
444
|
+
// A missing repo/ref (or a private one we can't reach) is a hard
|
|
445
|
+
// not-found, surfaced as zero files. Anything else — network loss, a
|
|
446
|
+
// transient GitHub outage — is retryable, so map it to a 503.
|
|
447
|
+
if (isGitRefNotFound(err)) return { fileCount: 0, commit: null };
|
|
448
|
+
throw new PluginSourceUnavailableError(
|
|
449
|
+
`git clone failed for ${sourceLabel(source)} @ ${source.ref}: ${gitErrorText(err)}`,
|
|
450
|
+
503,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
await runGit(["checkout", "--quiet", "FETCH_HEAD"], { cwd: cloneDir });
|
|
455
|
+
|
|
456
|
+
let commit: string | null = null;
|
|
457
|
+
try {
|
|
458
|
+
const { stdout } = await runGit(["rev-parse", "HEAD"], { cwd: cloneDir });
|
|
459
|
+
commit = stdout.trim() || null;
|
|
460
|
+
} catch {
|
|
461
|
+
// Provenance is best-effort; a missing commit must not fail the install.
|
|
462
|
+
commit = null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const srcRoot = source.rootPath
|
|
466
|
+
? join(cloneDir, source.rootPath)
|
|
467
|
+
: cloneDir;
|
|
468
|
+
if (!existsSync(srcRoot) || !statSync(srcRoot).isDirectory()) {
|
|
469
|
+
return { fileCount: 0, commit };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const fileCount = copyTreeSkippingGit(srcRoot, destDir);
|
|
473
|
+
return { fileCount, commit };
|
|
474
|
+
} finally {
|
|
475
|
+
rmSync(cloneDir, { recursive: true, force: true });
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Recursively copy regular files from `srcRoot` into `destDir`, skipping the
|
|
481
|
+
* top-level `.git` directory and any symlinks. Returns the file count.
|
|
482
|
+
*/
|
|
483
|
+
function copyTreeSkippingGit(srcRoot: string, destDir: string): number {
|
|
484
|
+
let count = 0;
|
|
485
|
+
const walk = (relDir: string): void => {
|
|
486
|
+
const absDir = relDir ? join(srcRoot, relDir) : srcRoot;
|
|
487
|
+
for (const entry of readdirSync(absDir, { withFileTypes: true })) {
|
|
488
|
+
// Drop git metadata and symlinks: the loader follows neither, and a
|
|
489
|
+
// symlink could otherwise point outside the staging tree.
|
|
490
|
+
if (relDir === "" && entry.name === ".git") continue;
|
|
491
|
+
if (entry.isSymbolicLink()) continue;
|
|
492
|
+
|
|
493
|
+
const rel = relDir ? join(relDir, entry.name) : entry.name;
|
|
494
|
+
if (entry.isDirectory()) {
|
|
495
|
+
walk(rel);
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
if (!entry.isFile()) continue;
|
|
499
|
+
|
|
500
|
+
const dest = join(destDir, rel);
|
|
501
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
502
|
+
copyFileSync(join(srcRoot, rel), dest);
|
|
503
|
+
count++;
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
walk("");
|
|
507
|
+
return count;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** True when a git fetch failed because the repo or ref is unreachable. */
|
|
511
|
+
function isGitRefNotFound(err: unknown): boolean {
|
|
512
|
+
const text = gitErrorText(err).toLowerCase();
|
|
513
|
+
return [
|
|
514
|
+
"could not find remote ref",
|
|
515
|
+
"couldn't find remote ref",
|
|
516
|
+
"remote branch",
|
|
517
|
+
"repository not found",
|
|
518
|
+
"could not read from remote repository",
|
|
519
|
+
"could not read username",
|
|
520
|
+
"terminal prompts disabled",
|
|
521
|
+
"authentication failed",
|
|
522
|
+
].some((needle) => text.includes(needle));
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Extract a stderr/message blob from a spawn error for classification/logging. */
|
|
526
|
+
function gitErrorText(err: unknown): string {
|
|
527
|
+
if (err instanceof Error) {
|
|
528
|
+
const withStreams = err as Error & { stderr?: unknown };
|
|
529
|
+
const stderr =
|
|
530
|
+
typeof withStreams.stderr === "string" ? withStreams.stderr : "";
|
|
531
|
+
return `${err.message} ${stderr}`.trim();
|
|
532
|
+
}
|
|
533
|
+
return String(err);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Hardened `git` runner used in production. Strips inherited `GIT_*` vars
|
|
538
|
+
* (which could redirect config, hooks, or the object store), disables the
|
|
539
|
+
* credential prompt so a private/missing repo fails fast instead of hanging
|
|
540
|
+
* the daemon, and augments `PATH` so the real git is found when the daemon is
|
|
541
|
+
* launched from a macOS `.app` bundle with a minimal environment.
|
|
542
|
+
*/
|
|
543
|
+
export const defaultGitRunner: GitRunner = async (args, opts) => {
|
|
544
|
+
const { stdout } = await execFileAsync("git", [...args], {
|
|
545
|
+
cwd: opts.cwd,
|
|
546
|
+
encoding: "utf8",
|
|
547
|
+
timeout: GIT_TIMEOUT_MS,
|
|
548
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
549
|
+
env: pluginGitEnv(),
|
|
550
|
+
});
|
|
551
|
+
return { stdout };
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
function pluginGitEnv(): NodeJS.ProcessEnv {
|
|
555
|
+
const env: NodeJS.ProcessEnv = {};
|
|
556
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
557
|
+
if (value !== undefined && !key.startsWith("GIT_")) env[key] = value;
|
|
558
|
+
}
|
|
559
|
+
env.GIT_TERMINAL_PROMPT = "0";
|
|
560
|
+
const extraPaths = ["/opt/homebrew/bin", "/usr/local/bin"];
|
|
561
|
+
const current = (env.PATH ?? "").split(":").filter(Boolean);
|
|
562
|
+
const missing = extraPaths.filter((p) => !current.includes(p));
|
|
563
|
+
if (missing.length > 0) {
|
|
564
|
+
env.PATH = [...current, ...missing].join(":");
|
|
565
|
+
}
|
|
566
|
+
return env;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Write the install-provenance sidecar into the staged plugin root, recording
|
|
571
|
+
* the resolved source coordinates and commit so we can later report or verify
|
|
572
|
+
* exactly what is installed. Hidden (dot-prefixed) so the daemon loader, which
|
|
573
|
+
* skips dotfiles, never mistakes it for plugin code.
|
|
574
|
+
*/
|
|
575
|
+
function writeInstallManifest(
|
|
576
|
+
stagingDir: string,
|
|
577
|
+
name: string,
|
|
578
|
+
source: PluginFetchSource,
|
|
579
|
+
ref: string,
|
|
580
|
+
commit: string | null,
|
|
581
|
+
): void {
|
|
582
|
+
const manifest = {
|
|
583
|
+
name,
|
|
584
|
+
source: {
|
|
585
|
+
kind: source.kind,
|
|
586
|
+
owner: source.owner,
|
|
587
|
+
repo: source.repo,
|
|
588
|
+
path: source.rootPath || undefined,
|
|
589
|
+
ref,
|
|
590
|
+
},
|
|
591
|
+
commit: commit ?? undefined,
|
|
592
|
+
installedAt: new Date().toISOString(),
|
|
593
|
+
};
|
|
594
|
+
writeFileSync(
|
|
595
|
+
join(stagingDir, INSTALL_MANIFEST_FILENAME),
|
|
596
|
+
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Recursively copy a first-party plugin directory via the GitHub Contents API.
|
|
602
|
+
*
|
|
603
|
+
* First-party plugins live in our own monorepo as a small handful of files, so
|
|
604
|
+
* the per-directory walk stays well within the unauthenticated rate limit —
|
|
605
|
+
* and avoids cloning the entire repository just to install one plugin. Returns
|
|
606
|
+
* the number of files written; zero means the directory doesn't exist at this
|
|
607
|
+
* ref, which the caller maps to a not-found error.
|
|
608
|
+
*/
|
|
209
609
|
async function copyDir(
|
|
610
|
+
owner: string,
|
|
611
|
+
repo: string,
|
|
210
612
|
apiPath: string,
|
|
211
613
|
ref: string,
|
|
212
614
|
destDir: string,
|
|
213
615
|
fetchFn: FetchLike,
|
|
214
616
|
): Promise<number> {
|
|
215
|
-
const entries = await listDir(apiPath, ref, fetchFn);
|
|
617
|
+
const entries = await listDir(owner, repo, apiPath, ref, fetchFn);
|
|
216
618
|
if (entries === null) return 0;
|
|
217
619
|
|
|
218
620
|
let count = 0;
|
|
219
621
|
for (const entry of entries) {
|
|
622
|
+
// The daemon loader follows neither symlinks nor submodules; skip them.
|
|
623
|
+
if (entry.type === "symlink" || entry.type === "submodule") continue;
|
|
220
624
|
assertSafeFilename("entry name", entry.name);
|
|
625
|
+
|
|
221
626
|
if (entry.type === "dir") {
|
|
222
627
|
const subDest = join(destDir, entry.name);
|
|
223
628
|
mkdirSync(subDest, { recursive: true });
|
|
224
|
-
count += await copyDir(entry.path, ref, subDest, fetchFn);
|
|
629
|
+
count += await copyDir(owner, repo, entry.path, ref, subDest, fetchFn);
|
|
225
630
|
continue;
|
|
226
631
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
continue;
|
|
231
|
-
}
|
|
232
|
-
// Skip symlink + submodule deliberately. The daemon-side loader does not
|
|
233
|
-
// follow either, so reproducing them in the install target adds risk
|
|
234
|
-
// without value.
|
|
632
|
+
|
|
633
|
+
await copyFile(entry, join(destDir, entry.name), fetchFn);
|
|
634
|
+
count++;
|
|
235
635
|
}
|
|
236
636
|
return count;
|
|
237
637
|
}
|
|
238
638
|
|
|
639
|
+
/** Download one file entry from the Contents API into `dest`. */
|
|
640
|
+
async function copyFile(
|
|
641
|
+
entry: GitHubContentEntry,
|
|
642
|
+
dest: string,
|
|
643
|
+
fetchFn: FetchLike,
|
|
644
|
+
): Promise<void> {
|
|
645
|
+
if (!entry.download_url) {
|
|
646
|
+
throw new Error(`No download URL for ${entry.path}`);
|
|
647
|
+
}
|
|
648
|
+
const res = await githubFetch(
|
|
649
|
+
entry.download_url,
|
|
650
|
+
"application/octet-stream",
|
|
651
|
+
fetchFn,
|
|
652
|
+
);
|
|
653
|
+
if (!res.ok) {
|
|
654
|
+
const label = `Download failed for ${entry.path}: HTTP ${res.status}`;
|
|
655
|
+
if (isTransientUpstreamStatus(res)) {
|
|
656
|
+
throw new PluginSourceUnavailableError(label, res.status);
|
|
657
|
+
}
|
|
658
|
+
throw new Error(label);
|
|
659
|
+
}
|
|
660
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
661
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
662
|
+
writeFileSync(dest, buf);
|
|
663
|
+
}
|
|
664
|
+
|
|
239
665
|
async function listDir(
|
|
666
|
+
owner: string,
|
|
667
|
+
repo: string,
|
|
240
668
|
apiPath: string,
|
|
241
669
|
ref: string,
|
|
242
670
|
fetchFn: FetchLike,
|
|
243
671
|
): Promise<readonly GitHubContentEntry[] | null> {
|
|
244
672
|
const url =
|
|
245
|
-
`https://api.github.com/repos/${
|
|
673
|
+
`https://api.github.com/repos/${owner}/${repo}` +
|
|
246
674
|
`/contents/${encodeURIComponent(apiPath).replaceAll("%2F", "/")}?ref=${encodeURIComponent(ref)}`;
|
|
247
675
|
|
|
248
676
|
const res = await githubFetch(url, "application/vnd.github+json", fetchFn);
|
|
249
677
|
if (res.status === 404) return null;
|
|
250
678
|
if (!res.ok) {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
679
|
+
const label = `GitHub contents listing failed for ${apiPath} @ ${ref}: HTTP ${res.status}`;
|
|
680
|
+
if (isTransientUpstreamStatus(res)) {
|
|
681
|
+
throw new PluginSourceUnavailableError(label, res.status);
|
|
682
|
+
}
|
|
683
|
+
throw new Error(label);
|
|
254
684
|
}
|
|
255
685
|
|
|
256
686
|
const body = (await res.json()) as unknown;
|
|
@@ -263,27 +693,6 @@ async function listDir(
|
|
|
263
693
|
return body as readonly GitHubContentEntry[];
|
|
264
694
|
}
|
|
265
695
|
|
|
266
|
-
async function copyFile(
|
|
267
|
-
entry: GitHubContentEntry,
|
|
268
|
-
destDir: string,
|
|
269
|
-
fetchFn: FetchLike,
|
|
270
|
-
): Promise<void> {
|
|
271
|
-
if (!entry.download_url) {
|
|
272
|
-
throw new Error(`GitHub contents entry has no download_url: ${entry.path}`);
|
|
273
|
-
}
|
|
274
|
-
const res = await githubFetch(entry.download_url, "application/octet-stream", fetchFn);
|
|
275
|
-
if (!res.ok) {
|
|
276
|
-
throw new Error(`Download failed for ${entry.path}: HTTP ${res.status}`);
|
|
277
|
-
}
|
|
278
|
-
const buf = Buffer.from(await res.arrayBuffer());
|
|
279
|
-
// entry.name was already validated by the caller; assert again as a
|
|
280
|
-
// belt-and-braces guard so copyFile is safe to call from future paths.
|
|
281
|
-
assertSafeFilename("file entry name", entry.name);
|
|
282
|
-
const dest = join(destDir, entry.name);
|
|
283
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
284
|
-
writeFileSync(dest, buf);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
696
|
/**
|
|
288
697
|
* Wraps `fetchFn` with the headers we want to send to GitHub for every
|
|
289
698
|
* request. Unauthenticated — the canonical source is a public repo, so
|