@vellumai/assistant 0.8.10 → 0.8.11-staging.1
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/bun.lock +62 -1
- package/docs/workspace-tools.md +196 -0
- package/examples/plugins/echo/README.md +3 -3
- package/knip.json +1 -0
- package/openapi.yaml +460 -128
- package/package.json +2 -1
- package/scripts/build-plugin-api.ts +299 -0
- package/src/__tests__/agent-loop-callsite-precedence.test.ts +7 -0
- package/src/__tests__/agent-loop-compaction-events.test.ts +197 -0
- package/src/__tests__/agent-loop-exit-reason.test.ts +93 -96
- package/src/__tests__/agent-loop-mutable-latest-user-message.test.ts +2 -0
- package/src/__tests__/agent-loop-output-hooks.test.ts +274 -1
- package/src/__tests__/agent-loop-override-profile.test.ts +3 -0
- package/src/__tests__/agent-loop-provider-error-recording.test.ts +4 -0
- package/src/__tests__/agent-loop-thinking.test.ts +4 -0
- package/src/__tests__/agent-loop.test.ts +578 -5
- package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +0 -1
- package/src/__tests__/approval-cascade.test.ts +1 -0
- package/src/__tests__/background-workers-disk-pressure.test.ts +0 -2
- package/src/__tests__/btw-routes.test.ts +0 -1
- package/src/__tests__/build-persisted-content.test.ts +75 -1
- package/src/__tests__/catalog-install-normalize.test.ts +141 -0
- package/src/__tests__/ces-startup-timeout.test.ts +60 -0
- package/src/__tests__/compaction-events.test.ts +1 -0
- package/src/__tests__/config-managed-gemini-defaults.test.ts +2 -46
- package/src/__tests__/context-overflow-reducer.test.ts +264 -124
- package/src/__tests__/context-window-manager-overflow-rung.test.ts +351 -0
- package/src/__tests__/conversation-abort-tool-results.test.ts +1 -1
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +13 -5
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +284 -455
- package/src/__tests__/conversation-agent-loop.test.ts +131 -551
- package/src/__tests__/conversation-app-control-instantiation.test.ts +13 -0
- package/src/__tests__/conversation-confirmation-signals.test.ts +1 -0
- package/src/__tests__/conversation-fork-crud.test.ts +259 -0
- package/src/__tests__/conversation-history-web-search.test.ts +1 -1
- package/src/__tests__/conversation-lifecycle.test.ts +257 -1
- package/src/__tests__/conversation-process-callsite.test.ts +1 -0
- package/src/__tests__/conversation-provider-retry-repair.test.ts +38 -377
- package/src/__tests__/conversation-queue.test.ts +1 -39
- package/src/__tests__/conversation-runtime-assembly.test.ts +119 -8
- package/src/__tests__/conversation-skill-tools.test.ts +491 -5
- package/src/__tests__/conversation-slash-queue.test.ts +1 -1
- package/src/__tests__/conversation-slash-unknown.test.ts +1 -0
- package/src/__tests__/conversation-speed-override.test.ts +1 -0
- package/src/__tests__/conversation-store.test.ts +74 -0
- package/src/__tests__/conversation-surfaces-app-control.test.ts +4 -1
- package/src/__tests__/conversation-tool-setup-attribution.test.ts +323 -0
- package/src/__tests__/conversation-tool-setup-tools-disabled.test.ts +34 -0
- package/src/__tests__/conversation-workspace-cache-state.test.ts +1 -0
- package/src/__tests__/conversation-workspace-injection.test.ts +1 -1
- package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -0
- package/src/__tests__/corrected-target.test.ts +93 -0
- package/src/__tests__/credential-execution-feature-gates.test.ts +3 -5
- package/src/__tests__/credential-execution-tools.test.ts +23 -11
- package/src/__tests__/credential-security-invariants.test.ts +6 -1
- package/src/__tests__/db-schedule-syntax-migration.test.ts +80 -0
- package/src/__tests__/device-id.test.ts +70 -1
- package/src/__tests__/embedding-managed-proxy-selection.test.ts +6 -40
- package/src/__tests__/empty-response-hook.test.ts +242 -66
- package/src/__tests__/external-plugin-loader.test.ts +0 -31
- package/src/__tests__/get-skill-detail-audit.test.ts +43 -1
- package/src/__tests__/guardian-routing-invariants.test.ts +91 -0
- package/src/__tests__/history-repair-hook.test.ts +228 -3
- package/src/__tests__/host-app-control-proxy.test.ts +45 -0
- package/src/__tests__/host-browser-proxy.test.ts +254 -9
- package/src/__tests__/identity-routes.test.ts +1 -0
- package/src/__tests__/image-recovery-hook.test.ts +387 -0
- package/src/__tests__/injector-chain.test.ts +5 -4
- package/src/__tests__/injector-v3-suppression.test.ts +373 -47
- package/src/__tests__/intent-routing.test.ts +7 -0
- package/src/__tests__/memory-retrieval-hook.test.ts +117 -15
- package/src/__tests__/notification-decision-strategy.test.ts +3 -3
- package/src/__tests__/oauth-store.test.ts +0 -85
- package/src/__tests__/{context-overflow-policy.test.ts → overflow-policy.test.ts} +1 -1
- package/src/__tests__/parallel-tool.benchmark.test.ts +4 -0
- package/src/__tests__/persist-unsendable-image-downscale.test.ts +29 -9
- package/src/__tests__/persist-unsendable-image.test.ts +4 -4
- package/src/__tests__/persistence-secret-redaction.test.ts +78 -0
- package/src/__tests__/plugin-bootstrap.test.ts +82 -73
- package/src/__tests__/plugin-tool-contribution.test.ts +7 -4
- package/src/__tests__/plugin-types.test.ts +0 -8
- package/src/__tests__/provider-catalog-visibility.test.ts +1 -9
- package/src/__tests__/prune-old-conversations-job.test.ts +99 -0
- package/src/__tests__/registry.test.ts +240 -1
- package/src/__tests__/require-fresh-approval.test.ts +3 -0
- package/src/__tests__/schedule-routes.test.ts +116 -1
- package/src/__tests__/schedule-store.test.ts +28 -0
- package/src/__tests__/schedule-tools.test.ts +94 -1
- package/src/__tests__/server-history-render.test.ts +39 -0
- package/src/__tests__/skill-projection-feature-flag.test.ts +13 -0
- package/src/__tests__/skill-projection.benchmark.test.ts +25 -7
- package/src/__tests__/skills.test.ts +202 -0
- package/src/__tests__/slim-skill-category.test.ts +195 -0
- package/src/__tests__/strip-memory-injections.test.ts +33 -39
- package/src/__tests__/test-support/tool-invocation-seed.ts +79 -0
- package/src/__tests__/title-generate-hook.test.ts +9 -7
- package/src/__tests__/tool-audit-listener.test.ts +264 -1
- package/src/__tests__/tool-error-hook.test.ts +4 -3
- package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +1 -0
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +273 -0
- package/src/__tests__/tool-result-truncate-hook.test.ts +1 -0
- package/src/__tests__/tool-start-timestamp.test.ts +218 -0
- package/src/__tests__/tools-get-route.test.ts +202 -0
- package/src/__tests__/workspace-tool-loader.test.ts +319 -0
- package/src/__tests__/workspace-tools-watcher-flag.test.ts +70 -0
- package/src/agent/loop.ts +569 -319
- package/src/api/events/tool-result.ts +9 -0
- package/src/api/events/tool-use-start.ts +7 -0
- package/src/api/index.ts +10 -0
- package/src/api/responses/conversation-message.ts +135 -27
- package/src/api/responses/memory-v3-selection-log.ts +4 -4
- package/src/approvals/guardian-request-resolvers.ts +26 -0
- package/src/browser-session/backends/host-bridge.ts +29 -0
- package/src/browser-session/index.ts +1 -0
- package/src/browser-session/types.ts +5 -1
- package/src/cli/commands/__tests__/schedules.test.ts +62 -4
- package/src/cli/commands/__tests__/skills.test.ts +53 -0
- package/src/cli/commands/channel-verification-sessions.ts +6 -6
- package/src/cli/commands/inference-providers.ts +0 -8
- package/src/cli/commands/plugins.ts +2 -2
- package/src/cli/commands/schedules.ts +27 -4
- package/src/cli/commands/skills.ts +187 -146
- package/src/cli/commands/tools.ts +106 -0
- package/src/cli/lib/__tests__/install-from-github.test.ts +256 -328
- package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +6 -2
- package/src/cli/lib/__tests__/plugin-details.test.ts +10 -16
- package/src/cli/lib/__tests__/plugin-marketplace.test.ts +2 -2
- package/src/cli/lib/__tests__/search-plugins.test.ts +145 -240
- package/src/cli/lib/install-from-github.ts +187 -117
- package/src/cli/lib/plugin-catalog-cache.ts +9 -9
- package/src/cli/lib/plugin-details.ts +38 -68
- package/src/cli/lib/plugin-marketplace.ts +42 -14
- package/src/cli/lib/search-plugins.ts +29 -129
- package/src/cli/program.ts +2 -0
- package/src/config/bundled-skills/acp/SKILL.md +1 -0
- package/src/config/bundled-skills/app-builder/SKILL.md +1 -0
- package/src/config/bundled-skills/app-control/SKILL.md +1 -0
- package/src/config/bundled-skills/computer-use/SKILL.md +1 -0
- package/src/config/bundled-skills/contacts/SKILL.md +1 -0
- package/src/config/bundled-skills/document-editor/SKILL.md +1 -0
- package/src/config/bundled-skills/followups/SKILL.md +1 -0
- package/src/config/bundled-skills/image-studio/SKILL.md +1 -0
- package/src/config/bundled-skills/media-processing/SKILL.md +1 -0
- package/src/config/bundled-skills/messaging/SKILL.md +1 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +1 -0
- package/src/config/bundled-skills/playbooks/SKILL.md +1 -0
- package/src/config/bundled-skills/schedule/SKILL.md +1 -0
- package/src/config/bundled-skills/schedule/TOOLS.json +11 -3
- package/src/config/bundled-skills/sequences/SKILL.md +1 -0
- package/src/config/bundled-skills/settings/SKILL.md +1 -0
- package/src/config/bundled-skills/skill-management/SKILL.md +101 -1
- package/src/config/bundled-skills/subagent/SKILL.md +1 -0
- package/src/config/bundled-skills/transcribe/SKILL.md +1 -0
- package/src/config/env-registry.ts +23 -0
- package/src/config/feature-flag-registry.json +17 -81
- package/src/config/loader.ts +5 -22
- package/src/config/schema.ts +2 -0
- package/src/config/schemas/__tests__/compaction-logs.test.ts +56 -0
- package/src/config/schemas/__tests__/memory-v2.test.ts +0 -1
- package/src/config/schemas/__tests__/memory-v3.test.ts +61 -1
- package/src/config/schemas/compaction-logs.ts +79 -0
- package/src/config/schemas/memory-v2.ts +0 -8
- package/src/config/schemas/memory-v3.ts +104 -33
- package/src/config/seed-inference-profiles.ts +1 -1
- package/src/config/skills.ts +117 -47
- package/src/context/compactor.ts +11 -0
- package/src/context/strip-injections.ts +38 -4
- package/src/credential-execution/feature-gates.ts +0 -21
- package/src/credential-execution/startup-timeout.ts +32 -4
- package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +18 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +140 -91
- package/src/daemon/conversation-agent-loop.ts +123 -658
- package/src/daemon/conversation-error.ts +6 -33
- package/src/daemon/conversation-lifecycle.ts +1 -1
- package/src/daemon/conversation-runtime-assembly.ts +183 -22
- package/src/daemon/conversation-skill-tools.ts +137 -8
- package/src/daemon/conversation-slash.ts +0 -14
- package/src/daemon/conversation-store.ts +2 -19
- package/src/daemon/conversation-tool-setup.ts +87 -1
- package/src/daemon/conversation.ts +119 -50
- package/src/daemon/external-plugins-bootstrap.ts +36 -95
- package/src/daemon/handlers/config-channels.ts +11 -2
- package/src/daemon/handlers/shared.ts +9 -1
- package/src/daemon/handlers/skills.ts +10 -4
- package/src/daemon/host-app-control-proxy.ts +72 -57
- package/src/daemon/host-browser-proxy.ts +117 -22
- package/src/daemon/lifecycle.ts +34 -5
- package/src/daemon/message-protocol.ts +0 -7
- package/src/daemon/message-types/schedules.ts +1 -0
- package/src/daemon/message-types/skills.ts +17 -0
- package/src/daemon/providers-setup.ts +3 -0
- package/src/daemon/server.ts +3 -3
- package/src/daemon/tool-setup-types.ts +9 -3
- package/src/daemon/trust-context.ts +23 -0
- package/src/daemon/workspace-tools-watcher.ts +324 -0
- package/src/events/tool-audit-listener.ts +78 -16
- package/src/events/tool-metrics-listener.ts +2 -5
- package/src/memory/__tests__/compaction-log-writer-clickhouse.test.ts +227 -0
- package/src/memory/__tests__/conversation-queries.test.ts +176 -0
- package/src/memory/__tests__/jobs-worker-v2-schedule.test.ts +20 -32
- package/src/memory/compaction-log-writer-clickhouse.ts +418 -0
- package/src/memory/conversation-crud.ts +134 -3
- package/src/memory/conversation-queries.ts +64 -7
- package/src/memory/db-init.ts +14 -0
- package/src/memory/embedding-backend.test.ts +130 -1
- package/src/memory/embedding-backend.ts +79 -106
- package/src/memory/embedding-gemini.ts +5 -0
- package/src/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +12 -0
- package/src/memory/graph/__tests__/handle-remember-v2.test.ts +19 -0
- package/src/memory/graph/conversation-graph-memory.ts +36 -25
- package/src/memory/graph/tool-handlers.ts +3 -0
- package/src/memory/job-handlers/cleanup.ts +3 -1
- package/src/memory/jobs-store.ts +0 -28
- package/src/memory/jobs-worker.ts +10 -22
- package/src/memory/memory-marker.ts +29 -0
- package/src/memory/memory-retrospective-startup-cleanup.ts +1 -1
- package/src/memory/migrations/268-add-memory-v3-selections.ts +6 -0
- package/src/memory/migrations/270-schedule-description.ts +36 -0
- package/src/memory/migrations/275-tool-invocations-add-skill-id.test.ts +81 -0
- package/src/memory/migrations/275-tool-invocations-add-skill-id.ts +20 -0
- package/src/memory/migrations/276-tool-invocations-created-at-id-index.test.ts +68 -0
- package/src/memory/migrations/276-tool-invocations-created-at-id-index.ts +20 -0
- package/src/memory/migrations/277-add-memory-v3-ever-injected.ts +29 -0
- package/src/memory/migrations/278-tool-invocations-telemetry-columns.test.ts +96 -0
- package/src/memory/migrations/278-tool-invocations-telemetry-columns.ts +39 -0
- package/src/memory/migrations/279-create-skill-loaded-events.test.ts +84 -0
- package/src/memory/migrations/279-create-skill-loaded-events.ts +26 -0
- package/src/memory/migrations/280-conversations-surfaced-at.test.ts +88 -0
- package/src/memory/migrations/280-conversations-surfaced-at.ts +24 -0
- package/src/memory/migrations/index.ts +10 -0
- package/src/memory/migrations/registry.ts +8 -0
- package/src/memory/schema/conversations.ts +16 -0
- package/src/memory/schema/infrastructure.ts +26 -0
- package/src/memory/skill-loaded-events-store.test.ts +160 -0
- package/src/memory/skill-loaded-events-store.ts +95 -0
- package/src/memory/tool-executed-events-store.test.ts +219 -0
- package/src/memory/tool-executed-events-store.ts +102 -0
- package/src/memory/tool-usage-store.ts +15 -3
- package/src/memory/v2/__tests__/consolidation-job.test.ts +117 -12
- package/src/memory/v2/__tests__/consolidation-prompt-flag-gating-guard.test.ts +189 -0
- package/src/memory/v2/__tests__/injected-block-slugs.test.ts +90 -0
- package/src/memory/v2/__tests__/page-store.test.ts +33 -0
- package/src/memory/v2/__tests__/prompts-consolidation.test.ts +88 -15
- package/src/memory/v2/activation-store.ts +50 -1
- package/src/memory/v2/consolidation-job.ts +113 -29
- package/src/memory/v2/injected-block-slugs.ts +79 -0
- package/src/memory/v2/injection.ts +6 -1
- package/src/memory/v2/prompts/consolidation.ts +414 -13
- package/src/memory/v2/router.ts +2 -28
- package/src/memory/v2/static-context.ts +1 -1
- package/src/memory/v2/types.ts +16 -0
- package/src/notifications/__tests__/copy-composer.test.ts +244 -0
- package/src/notifications/access-request-copy.ts +298 -0
- package/src/notifications/adapters/slack.ts +3 -3
- package/src/notifications/adapters/telegram.ts +2 -1
- package/src/notifications/copy-composer.ts +49 -267
- package/src/notifications/decision-engine.ts +16 -35
- package/src/notifications/home-feed-side-effect.ts +1 -6
- package/src/oauth/oauth-store.ts +0 -9
- package/src/permissions/checker.test.ts +83 -1
- package/src/permissions/checker.ts +25 -2
- package/src/permissions/gateway-threshold-reader.test.ts +182 -0
- package/src/permissions/gateway-threshold-reader.ts +80 -0
- package/src/platform/client.ts +1 -3
- package/src/platform/feature-gate.ts +3 -12
- package/src/plugin-api/constants.ts +4 -2
- package/src/plugin-api/index.ts +63 -11
- package/src/plugin-api/types.ts +236 -71
- package/src/plugins/defaults/compaction/compact.ts +66 -2
- package/src/plugins/defaults/compaction/context-overflow-reducer.ts +240 -32
- package/src/plugins/defaults/compaction/corrected-target.ts +53 -0
- package/src/{daemon/context-overflow-policy.ts → plugins/defaults/compaction/overflow-policy.ts} +1 -1
- package/src/plugins/defaults/compaction/window-manager.ts +303 -1
- package/src/plugins/defaults/empty-response/hooks/post-model-call.ts +173 -0
- package/src/plugins/defaults/empty-response/hooks/stop.ts +11 -115
- package/src/plugins/defaults/empty-response/nudge-state-store.ts +46 -0
- package/src/plugins/defaults/history-repair/hooks/post-model-call.ts +50 -0
- package/src/plugins/defaults/history-repair/hooks/stop.ts +22 -0
- package/src/plugins/defaults/history-repair/repair-state-store.ts +51 -0
- package/src/plugins/defaults/history-repair/terminal.ts +39 -2
- package/src/plugins/defaults/image-recovery/detect.ts +25 -0
- package/src/plugins/defaults/image-recovery/hooks/post-model-call.ts +73 -0
- package/src/plugins/defaults/image-recovery/hooks/stop.ts +22 -0
- package/src/plugins/defaults/image-recovery/image-recovery-state-store.ts +48 -0
- package/src/plugins/defaults/image-recovery/package.json +14 -0
- package/src/{daemon/persist-unsendable-image.ts → plugins/defaults/image-recovery/recover.ts} +67 -14
- package/src/plugins/defaults/index.ts +71 -5
- package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +76 -112
- package/src/plugins/defaults/memory-retrieval/hooks/{user-prompt-submit-temp.ts → user-prompt-submit.ts} +83 -74
- package/src/plugins/defaults/memory-retrieval/injector-chain.ts +14 -8
- package/src/plugins/defaults/memory-retrieval/injectors.ts +2 -18
- package/src/plugins/defaults/memory-retrieval/package.json +14 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +1157 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +683 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +161 -140
- package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +160 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +335 -316
- package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +145 -53
- package/src/plugins/defaults/memory-v3-shadow/__tests__/render-injection.test.ts +38 -1
- package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +18 -8
- package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +112 -71
- package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +192 -51
- package/src/plugins/defaults/memory-v3-shadow/__tests__/types.test.ts +4 -16
- package/src/plugins/defaults/memory-v3-shadow/card.test.ts +173 -0
- package/src/plugins/defaults/memory-v3-shadow/card.ts +116 -0
- package/src/plugins/defaults/memory-v3-shadow/core-set.test.ts +104 -0
- package/src/plugins/defaults/memory-v3-shadow/core-set.ts +59 -0
- package/src/plugins/defaults/memory-v3-shadow/ever-injected-store.test.ts +305 -0
- package/src/plugins/defaults/memory-v3-shadow/ever-injected-store.ts +278 -0
- package/src/plugins/defaults/memory-v3-shadow/hot-set.test.ts +138 -0
- package/src/plugins/defaults/memory-v3-shadow/hot-set.ts +85 -0
- package/src/plugins/defaults/memory-v3-shadow/injector.ts +331 -24
- package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +119 -13
- package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +169 -114
- package/src/plugins/defaults/memory-v3-shadow/page-content.ts +47 -16
- package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +144 -66
- package/src/plugins/defaults/memory-v3-shadow/prune.test.ts +758 -0
- package/src/plugins/defaults/memory-v3-shadow/prune.ts +471 -0
- package/src/plugins/defaults/memory-v3-shadow/render-injection.ts +68 -16
- package/src/plugins/defaults/memory-v3-shadow/selection-log-store.ts +19 -12
- package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +96 -43
- package/src/plugins/defaults/memory-v3-shadow/types.ts +34 -17
- package/src/plugins/defaults/title-generate/hooks/stop.ts +9 -11
- package/src/plugins/pipeline.ts +8 -5
- package/src/plugins/types.ts +5 -51
- package/src/providers/cache-control.ts +26 -0
- package/src/providers/inference/__tests__/base-url-route-validation.test.ts +1 -2
- package/src/providers/model-catalog.ts +13 -1
- package/src/providers/openai/__tests__/tool-choice-mapping.test.ts +147 -0
- package/src/providers/openai/chat-completions-provider.ts +46 -0
- package/src/providers/openai/responses-provider.ts +45 -0
- package/src/providers/registry.ts +0 -8
- package/src/runtime/__tests__/agent-wake.test.ts +0 -1
- package/src/runtime/agent-wake.ts +13 -0
- package/src/runtime/routes/__tests__/acp-routes.test.ts +151 -0
- package/src/runtime/routes/__tests__/consolidation-routes.test.ts +12 -50
- package/src/runtime/routes/__tests__/conversation-surface-routes.test.ts +322 -0
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +0 -62
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +31 -11
- package/src/runtime/routes/acp-routes.test.ts +106 -0
- package/src/runtime/routes/acp-routes.ts +248 -2
- package/src/runtime/routes/browser-tabs-routes.ts +1 -1
- package/src/runtime/routes/channel-verification-routes.ts +14 -5
- package/src/runtime/routes/chatgpt-subscription-auth-routes.ts +0 -14
- package/src/runtime/routes/consolidation-routes.ts +6 -82
- package/src/runtime/routes/conversation-list-routes.ts +6 -0
- package/src/runtime/routes/conversation-management-routes.ts +71 -0
- package/src/runtime/routes/identity-routes.ts +8 -0
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +33 -34
- package/src/runtime/routes/inference-provider-connection-routes.ts +0 -45
- package/src/runtime/routes/plugins-routes.ts +34 -45
- package/src/runtime/routes/schedule-routes.ts +43 -5
- package/src/runtime/routes/settings-routes.ts +140 -15
- package/src/runtime/routes/skills-routes.ts +18 -6
- package/src/runtime/services/__tests__/conversation-serializer.test.ts +140 -0
- package/src/runtime/services/conversation-serializer.ts +38 -1
- package/src/runtime/verification-outbound-actions.ts +147 -2
- package/src/runtime/verification-templates.ts +29 -3
- package/src/schedule/schedule-store.ts +19 -0
- package/src/skills/catalog-install.ts +77 -13
- package/src/tasks/task-scheduler.ts +1 -0
- package/src/telemetry/types.ts +66 -1
- package/src/telemetry/usage-telemetry-reporter.test.ts +542 -13
- package/src/telemetry/usage-telemetry-reporter.ts +213 -20
- package/src/tools/browser/__tests__/browser-execution-acquire.test.ts +49 -2
- package/src/tools/browser/__tests__/browser-status.test.ts +29 -5
- package/src/tools/browser/browser-execution.ts +27 -9
- package/src/tools/browser/cdp-client/__tests__/factory.test.ts +380 -4
- package/src/tools/browser/cdp-client/__tests__/host-bridge-cdp-client.test.ts +107 -0
- package/src/tools/browser/cdp-client/__tests__/types.test.ts +6 -1
- package/src/tools/browser/cdp-client/factory.ts +217 -17
- package/src/tools/browser/cdp-client/host-bridge-cdp-client.ts +67 -0
- package/src/tools/browser/cdp-client/types.ts +22 -2
- package/src/tools/credential-execution/make-authenticated-request.ts +2 -1
- package/src/tools/credential-execution/manage-secure-command-tool.ts +169 -164
- package/src/tools/credential-execution/run-authenticated-command.ts +2 -1
- package/src/tools/executor.ts +39 -7
- package/src/tools/registry.ts +387 -5
- package/src/tools/schedule/create.ts +16 -0
- package/src/tools/schedule/list.ts +12 -4
- package/src/tools/schedule/update.ts +12 -0
- package/src/tools/skills/load.ts +11 -6
- package/src/tools/terminal/safe-env.ts +2 -0
- package/src/tools/types.ts +65 -9
- package/src/tools/workspace-tools/loader.ts +673 -0
- package/src/usage/attribution.ts +28 -0
- package/src/util/device-id.ts +17 -3
- package/src/util/platform.ts +16 -0
- package/tsconfig.plugin-api.json +13 -0
- package/src/__tests__/plugin-external-api.test.ts +0 -68
- package/src/__tests__/plugin-skill-contribution.test.ts +0 -355
- package/src/daemon/message-types/browser.ts +0 -10
- package/src/notifications/__tests__/emit-signal-home-feed.test.ts +0 -187
- package/src/plugins/defaults/memory-v3-shadow/__tests__/fixtures/eval-turns.json +0 -36
- package/src/plugins/defaults/memory-v3-shadow/__tests__/fixtures/live-turns.json +0 -37
- package/src/plugins/defaults/memory-v3-shadow/__tests__/working-set-eviction.test.ts +0 -106
- package/src/plugins/defaults/memory-v3-shadow/__tests__/working-set-skeleton.test.ts +0 -44
- package/src/plugins/defaults/memory-v3-shadow/working-set.ts +0 -91
- package/src/plugins/external-api.ts +0 -114
- package/src/plugins/plugin-skill-contributions.ts +0 -292
|
@@ -0,0 +1,1157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end integration test + measured footprint gate for the memory-v3
|
|
3
|
+
* cache-aware carry rework (the whole PR 1–10 composition).
|
|
4
|
+
*
|
|
5
|
+
* SCOPE / ALTITUDE. A full daemon-assembly run is too heavy and too
|
|
6
|
+
* mock-fragile for a unit test (same altitude call as the sibling
|
|
7
|
+
* `live-integration.test.ts` / `shadow-integration.test.ts`). Instead this
|
|
8
|
+
* drives a scripted 10-turn conversation through the REAL pipeline units —
|
|
9
|
+
*
|
|
10
|
+
* orchestrate (core+hot stable prefix from real `loadCoreSet` /
|
|
11
|
+
* `computeHotSet`, real needle finder lane, pre-rendered prefix cards)
|
|
12
|
+
* → selectPool (real two-segment render + cache breakpoint; the PROVIDER
|
|
13
|
+
* is stubbed to return deterministic ids per scripted turn)
|
|
14
|
+
* → the real injectors (`memoryV3Injector` net-new cards + recordInjected
|
|
15
|
+
* + schedulePruneValve; `memoryV3SpotlightInjector` ephemeral window)
|
|
16
|
+
* → simulated runtime assembly (splice the card block onto the current
|
|
17
|
+
* user message; scoped spotlight strip-and-replace via the real
|
|
18
|
+
* `stripSpotlightInjections`) and metadata persistence (the
|
|
19
|
+
* user-prompt-submit hook's `memoryV3InjectedBlock` write)
|
|
20
|
+
* → the real prune valve against the live history (conversation-registry
|
|
21
|
+
* stubbed to the simulated message arrays)
|
|
22
|
+
* → rehydration from the temp DB (mirroring `daemon/conversation.ts`'s
|
|
23
|
+
* metadata splice + pruned-section filter) for the restart contract
|
|
24
|
+
*
|
|
25
|
+
* — and asserts the four contracts the rework ships on:
|
|
26
|
+
* 1. CACHE: the selector input's stable prefix is byte-identical across all
|
|
27
|
+
* turns (and carries the cache breakpoint); per-turn persistent renders
|
|
28
|
+
* are net-new cards only; prior turns' blocks stay frozen in history.
|
|
29
|
+
* 2. PRUNE: the valve trips when resident bytes exceed the cap, drops to
|
|
30
|
+
* target, exempts core/hot, and a pruned slug re-selected re-injects.
|
|
31
|
+
* 3. FORK: a fork inherits the dedup record and renders no duplicate cards.
|
|
32
|
+
* 4. RESTART: rebuilding history from the DB mid-script reproduces the live
|
|
33
|
+
* persistent layer byte-identically, with pruned sections still absent.
|
|
34
|
+
*
|
|
35
|
+
* The final test emits the measured per-turn footprint table (net-new card
|
|
36
|
+
* bytes and spotlight bytes as separate columns, plus resident bytes) — the
|
|
37
|
+
* cutover-gate evidence that steady-state per-turn fresh cost is
|
|
38
|
+
* net-new + spotlight, not O(working set).
|
|
39
|
+
*
|
|
40
|
+
* `mock.module` is process-global, so every stub delegates to the real
|
|
41
|
+
* implementation unless this file's tests are running (`carryMockActive`) —
|
|
42
|
+
* mirrors the sibling test files. Slugs/terms are generic placeholders.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
46
|
+
import { tmpdir } from "node:os";
|
|
47
|
+
import { join } from "node:path";
|
|
48
|
+
import { Database } from "bun:sqlite";
|
|
49
|
+
import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
|
|
50
|
+
|
|
51
|
+
import { drizzle } from "drizzle-orm/bun-sqlite";
|
|
52
|
+
|
|
53
|
+
import { stripSpotlightInjections } from "../../../../context/strip-injections.js";
|
|
54
|
+
import {
|
|
55
|
+
unwrapMemoryBlock,
|
|
56
|
+
wrapMemoryBlock,
|
|
57
|
+
} from "../../../../memory/memory-marker.js";
|
|
58
|
+
import { migrateAddMemoryV3Selections } from "../../../../memory/migrations/268-add-memory-v3-selections.js";
|
|
59
|
+
import { migrateAddMemoryV3EverInjected } from "../../../../memory/migrations/277-add-memory-v3-ever-injected.js";
|
|
60
|
+
import * as schema from "../../../../memory/schema.js";
|
|
61
|
+
import type { PageIndexEntry } from "../../../../memory/v2/page-index.js";
|
|
62
|
+
import type {
|
|
63
|
+
ContentBlock,
|
|
64
|
+
Message,
|
|
65
|
+
Provider,
|
|
66
|
+
ProviderResponse,
|
|
67
|
+
} from "../../../../providers/types.js";
|
|
68
|
+
import { cardBytes, renderCard } from "../card.js";
|
|
69
|
+
import { loadCoreSet } from "../core-set.js";
|
|
70
|
+
import type { EdgeGraph } from "../edge.js";
|
|
71
|
+
import { buildEdgeGraph } from "../edge.js";
|
|
72
|
+
import { buildSectionNeedle } from "../section-needle.js";
|
|
73
|
+
import { buildSectionIndex } from "../sections.js";
|
|
74
|
+
import {
|
|
75
|
+
MEMORY_V3_COMMIT_META_KEY,
|
|
76
|
+
type SectionIndex,
|
|
77
|
+
type Slug,
|
|
78
|
+
} from "../types.js";
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Module stubs (installed before the dynamic imports below; each delegates to
|
|
82
|
+
// the real implementation while `carryMockActive` is false).
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
let carryMockActive = false;
|
|
86
|
+
|
|
87
|
+
const realProvider = {
|
|
88
|
+
...(await import("../../../../providers/provider-send-message.js")),
|
|
89
|
+
};
|
|
90
|
+
const realFlags = {
|
|
91
|
+
...(await import("../../../../config/assistant-feature-flags.js")),
|
|
92
|
+
};
|
|
93
|
+
const realConfigLoader = { ...(await import("../../../../config/loader.js")) };
|
|
94
|
+
const realDbConnection = {
|
|
95
|
+
...(await import("../../../../memory/db-connection.js")),
|
|
96
|
+
};
|
|
97
|
+
const realDense = { ...(await import("../dense.js")) };
|
|
98
|
+
const realPageContent = { ...(await import("../page-content.js")) };
|
|
99
|
+
|
|
100
|
+
let providerStub: Provider | null = null;
|
|
101
|
+
mock.module("../../../../providers/provider-send-message.js", () => ({
|
|
102
|
+
...realProvider,
|
|
103
|
+
getConfiguredProvider: async (
|
|
104
|
+
...args: Parameters<typeof realProvider.getConfiguredProvider>
|
|
105
|
+
) =>
|
|
106
|
+
carryMockActive
|
|
107
|
+
? providerStub
|
|
108
|
+
: realProvider.getConfiguredProvider(...args),
|
|
109
|
+
extractToolUse: (response: ProviderResponse) =>
|
|
110
|
+
response.content.find((b) => b.type === "tool_use"),
|
|
111
|
+
}));
|
|
112
|
+
|
|
113
|
+
mock.module("../../../../util/logger.js", () => ({
|
|
114
|
+
getLogger: () =>
|
|
115
|
+
new Proxy({} as Record<string, unknown>, {
|
|
116
|
+
get: (_t, prop) => (prop === "child" ? () => ({}) : () => {}),
|
|
117
|
+
}),
|
|
118
|
+
}));
|
|
119
|
+
|
|
120
|
+
// The dense lane is inert in this fixture — the needle drives the finder tail.
|
|
121
|
+
mock.module("../dense.js", () => ({
|
|
122
|
+
...realDense,
|
|
123
|
+
denseLane: async (...args: Parameters<typeof realDense.denseLane>) =>
|
|
124
|
+
carryMockActive ? [] : realDense.denseLane(...args),
|
|
125
|
+
}));
|
|
126
|
+
|
|
127
|
+
let testSqlite: Database;
|
|
128
|
+
let testDb = makeDb();
|
|
129
|
+
function makeDb() {
|
|
130
|
+
testSqlite = new Database(":memory:");
|
|
131
|
+
const db = drizzle(testSqlite, { schema });
|
|
132
|
+
migrateAddMemoryV3EverInjected(db);
|
|
133
|
+
migrateAddMemoryV3Selections(db);
|
|
134
|
+
// Minimal `messages` shape — metadata persistence, the prune valve's
|
|
135
|
+
// v3-ownership scan, and the restart rehydration read only these columns.
|
|
136
|
+
testSqlite.run(/*sql*/ `
|
|
137
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
138
|
+
id TEXT PRIMARY KEY,
|
|
139
|
+
conversation_id TEXT NOT NULL,
|
|
140
|
+
role TEXT NOT NULL,
|
|
141
|
+
content TEXT NOT NULL,
|
|
142
|
+
metadata TEXT,
|
|
143
|
+
created_at INTEGER NOT NULL
|
|
144
|
+
)
|
|
145
|
+
`);
|
|
146
|
+
return db;
|
|
147
|
+
}
|
|
148
|
+
mock.module("../../../../memory/db-connection.js", () => ({
|
|
149
|
+
...realDbConnection,
|
|
150
|
+
getDb: () => (carryMockActive ? testDb : realDbConnection.getDb()),
|
|
151
|
+
getSqliteFrom: (db: unknown) =>
|
|
152
|
+
carryMockActive
|
|
153
|
+
? testSqlite
|
|
154
|
+
: realDbConnection.getSqliteFrom(
|
|
155
|
+
db as Parameters<typeof realDbConnection.getSqliteFrom>[0],
|
|
156
|
+
),
|
|
157
|
+
}));
|
|
158
|
+
|
|
159
|
+
/** Mutable prune config: `null` until the script opens the valve window. */
|
|
160
|
+
let pruneConfig: {
|
|
161
|
+
maxResidentBytes: number;
|
|
162
|
+
targetResidentBytes: number;
|
|
163
|
+
} | null = null;
|
|
164
|
+
const SPOTLIGHT_N = 6;
|
|
165
|
+
const SPOTLIGHT_WINDOW_TURNS = 2;
|
|
166
|
+
mock.module("../../../../config/loader.js", () => ({
|
|
167
|
+
...realConfigLoader,
|
|
168
|
+
getConfig: () =>
|
|
169
|
+
carryMockActive
|
|
170
|
+
? {
|
|
171
|
+
memory: {
|
|
172
|
+
v3: {
|
|
173
|
+
spotlight: {
|
|
174
|
+
n: SPOTLIGHT_N,
|
|
175
|
+
windowTurns: SPOTLIGHT_WINDOW_TURNS,
|
|
176
|
+
},
|
|
177
|
+
prune: pruneConfig ?? undefined,
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
: realConfigLoader.getConfig(),
|
|
182
|
+
}));
|
|
183
|
+
|
|
184
|
+
mock.module("../../../../config/assistant-feature-flags.js", () => ({
|
|
185
|
+
...realFlags,
|
|
186
|
+
isAssistantFeatureFlagEnabled: (
|
|
187
|
+
key: string,
|
|
188
|
+
config: Parameters<typeof realFlags.isAssistantFeatureFlagEnabled>[1],
|
|
189
|
+
) => {
|
|
190
|
+
if (!carryMockActive) {
|
|
191
|
+
return realFlags.isAssistantFeatureFlagEnabled(
|
|
192
|
+
key as Parameters<typeof realFlags.isAssistantFeatureFlagEnabled>[0],
|
|
193
|
+
config,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
return key === "memory-v3-live";
|
|
197
|
+
},
|
|
198
|
+
}));
|
|
199
|
+
|
|
200
|
+
// Cards render from the in-memory fixture corpus via the REAL renderer, so
|
|
201
|
+
// injected bytes are exactly what `renderCard` produces for these pages.
|
|
202
|
+
mock.module("../page-content.js", () => ({
|
|
203
|
+
...realPageContent,
|
|
204
|
+
renderV3CardContent: async (slug: Slug) =>
|
|
205
|
+
carryMockActive
|
|
206
|
+
? RAW[slug]
|
|
207
|
+
? renderCard(slug, RAW[slug])
|
|
208
|
+
: ""
|
|
209
|
+
: realPageContent.renderV3CardContent(slug),
|
|
210
|
+
}));
|
|
211
|
+
|
|
212
|
+
// The prune valve resolves the live conversation through the daemon registry.
|
|
213
|
+
// Route it at the simulated per-conversation histories (and keep the heavy
|
|
214
|
+
// daemon module graph out of this test process — same call as injection.test.ts).
|
|
215
|
+
const histories = new Map<string, Message[]>();
|
|
216
|
+
mock.module("../../../../daemon/conversation-registry.js", () => ({
|
|
217
|
+
findConversationOrSubagent: (conversationId: string) => {
|
|
218
|
+
const messages = histories.get(conversationId);
|
|
219
|
+
return messages ? { messages } : undefined;
|
|
220
|
+
},
|
|
221
|
+
}));
|
|
222
|
+
|
|
223
|
+
// Real orchestration is wired under the injectors' `observeTurn` seam: each
|
|
224
|
+
// scripted turn runs the REAL `orchestrate` over the fixture lanes and logs
|
|
225
|
+
// selections through the shadow plugin's REAL attribution/writer (so hot-set
|
|
226
|
+
// frecency and the prune valve's recency ranking see real rows), then
|
|
227
|
+
// normalizes the rows' `created_at` to a per-turn stamp so recency ranking is
|
|
228
|
+
// deterministic regardless of wall-clock resolution.
|
|
229
|
+
const { orchestrate } = await import("../orchestrate.js");
|
|
230
|
+
const realShadowPlugin = { ...(await import("../shadow-plugin.js")) };
|
|
231
|
+
|
|
232
|
+
/** The scripted query per (conversation, turn) — the selector's `keep` list
|
|
233
|
+
* lives in the module-level `keep` the provider stub reads. */
|
|
234
|
+
const scriptedTurns = new Map<string, string>();
|
|
235
|
+
|
|
236
|
+
async function scriptedObserveTurn(conversationId: string, turnIndex: number) {
|
|
237
|
+
if (!carryMockActive) {
|
|
238
|
+
return realShadowPlugin.observeTurn(conversationId, turnIndex);
|
|
239
|
+
}
|
|
240
|
+
const query = scriptedTurns.get(`${conversationId}:${turnIndex}`);
|
|
241
|
+
if (query === undefined) {
|
|
242
|
+
throw new Error(`no scripted turn for ${conversationId}:${turnIndex}`);
|
|
243
|
+
}
|
|
244
|
+
const result = await orchestrate(
|
|
245
|
+
{
|
|
246
|
+
conversationId,
|
|
247
|
+
turnNumber: turnIndex,
|
|
248
|
+
currentMessage: query,
|
|
249
|
+
recentContext: "prior context",
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
sectionIndex: lanes.sectionIndex,
|
|
253
|
+
needle: lanes.needle,
|
|
254
|
+
denseConfig: {} as never,
|
|
255
|
+
edgeGraph: lanes.edgeGraph,
|
|
256
|
+
coreSlugs: lanes.coreSlugs,
|
|
257
|
+
hotSlugs: lanes.hotSlugs,
|
|
258
|
+
prefixCards: lanes.prefixCards,
|
|
259
|
+
},
|
|
260
|
+
);
|
|
261
|
+
realShadowPlugin.writeSelections(
|
|
262
|
+
conversationId,
|
|
263
|
+
turnIndex,
|
|
264
|
+
realShadowPlugin.attributeSelections(result),
|
|
265
|
+
);
|
|
266
|
+
testSqlite
|
|
267
|
+
.query(
|
|
268
|
+
/*sql*/ `
|
|
269
|
+
UPDATE memory_v3_selections SET created_at = ?
|
|
270
|
+
WHERE conversation_id = ? AND turn = ?
|
|
271
|
+
`,
|
|
272
|
+
)
|
|
273
|
+
.run(BASE + turnIndex * 1000, conversationId, turnIndex);
|
|
274
|
+
return result;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
mock.module("../shadow-plugin.js", () => ({
|
|
278
|
+
...realShadowPlugin,
|
|
279
|
+
observeTurn: scriptedObserveTurn,
|
|
280
|
+
}));
|
|
281
|
+
|
|
282
|
+
const {
|
|
283
|
+
memoryV3Injector,
|
|
284
|
+
memoryV3SpotlightInjector,
|
|
285
|
+
resetMemoryV3InjectorStateForTests,
|
|
286
|
+
} = await import("../injector.js");
|
|
287
|
+
const {
|
|
288
|
+
forkEverInjected,
|
|
289
|
+
getActiveSlugs,
|
|
290
|
+
getInjected,
|
|
291
|
+
getPrunedSlugs,
|
|
292
|
+
MEMORY_V3_INJECTED_BLOCK_METADATA_KEY,
|
|
293
|
+
residentBytes,
|
|
294
|
+
} = await import("../ever-injected-store.js");
|
|
295
|
+
const { filterPrunedCardSections, flushPruneValveForTests, parseCardSections } =
|
|
296
|
+
await import("../prune.js");
|
|
297
|
+
const { renderCardsBlockInner, V3_CARDS_INJECTION_HEADER } =
|
|
298
|
+
await import("../render-injection.js");
|
|
299
|
+
const { computeHotSet } = await import("../hot-set.js");
|
|
300
|
+
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// Fixture corpus: 20 generic pages, each with a distinctive term so a needle
|
|
303
|
+
// query selects exactly the intended pages. `page-a`/`page-b` carry padded
|
|
304
|
+
// leads so the prune window (which must free their two cards) stays
|
|
305
|
+
// deterministic (their bytes exceed turn 7's incoming cards).
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
const PAGE_TERMS: Record<Slug, string> = {
|
|
309
|
+
"core-alpha": "tamarind",
|
|
310
|
+
"core-beta": "ugli",
|
|
311
|
+
"hot-one": "quince",
|
|
312
|
+
"hot-two": "raspberry",
|
|
313
|
+
"hot-three": "strawberry",
|
|
314
|
+
"page-a": "apple",
|
|
315
|
+
"page-b": "banana",
|
|
316
|
+
"page-c": "cherry",
|
|
317
|
+
"page-d": "dragonfruit",
|
|
318
|
+
"page-e": "elderberry",
|
|
319
|
+
"page-f": "fig",
|
|
320
|
+
"page-g": "guava",
|
|
321
|
+
"page-h": "honeydew",
|
|
322
|
+
"page-i": "imbe",
|
|
323
|
+
"page-j": "jackfruit",
|
|
324
|
+
"page-k": "kiwi",
|
|
325
|
+
"page-l": "lemon",
|
|
326
|
+
"page-m": "mango",
|
|
327
|
+
"page-n": "nectarine",
|
|
328
|
+
"page-o": "olive",
|
|
329
|
+
};
|
|
330
|
+
const ALL_SLUGS = Object.keys(PAGE_TERMS);
|
|
331
|
+
|
|
332
|
+
const LONG_LEAD_PAD =
|
|
333
|
+
" This page carries a deliberately longer descriptive lead so its card" +
|
|
334
|
+
" outweighs later cards and the scripted prune window frees exactly this" +
|
|
335
|
+
" page when the valve trips.";
|
|
336
|
+
|
|
337
|
+
function pageText(slug: Slug): string {
|
|
338
|
+
const term = PAGE_TERMS[slug]!;
|
|
339
|
+
const pad = slug === "page-a" || slug === "page-b" ? LONG_LEAD_PAD : "";
|
|
340
|
+
return (
|
|
341
|
+
`lead for ${slug} covering ${term}.${pad}\n\n` +
|
|
342
|
+
`## Detail\n${term} detail material for ${slug}\n\n` +
|
|
343
|
+
`## Notes\ngeneral notes for ${slug}`
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const RAW: Record<Slug, string> = Object.fromEntries(
|
|
348
|
+
ALL_SLUGS.map((slug) => [slug, pageText(slug)]),
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
/** A slug's card exactly as the real renderer produces it from the fixture. */
|
|
352
|
+
function card(slug: Slug): string {
|
|
353
|
+
return renderCard(slug, RAW[slug]!);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const CORE_SLUGS: Slug[] = ["core-alpha", "core-beta"];
|
|
357
|
+
const HOT_SLUGS: Slug[] = ["hot-one", "hot-two", "hot-three"];
|
|
358
|
+
|
|
359
|
+
const CONV = "conv-carry";
|
|
360
|
+
const FORK_CONV = "conv-carry-fork";
|
|
361
|
+
/** Fixed epoch base for all timestamps (determinism). */
|
|
362
|
+
const BASE = 1_700_000_000_000;
|
|
363
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
364
|
+
|
|
365
|
+
interface FixtureLanes {
|
|
366
|
+
sectionIndex: SectionIndex;
|
|
367
|
+
needle: ReturnType<typeof buildSectionNeedle>;
|
|
368
|
+
edgeGraph: EdgeGraph;
|
|
369
|
+
coreSlugs: Slug[];
|
|
370
|
+
hotSlugs: Slug[];
|
|
371
|
+
prefixCards: Map<Slug, string>;
|
|
372
|
+
}
|
|
373
|
+
let lanes: FixtureLanes;
|
|
374
|
+
let workspaceDir: string;
|
|
375
|
+
|
|
376
|
+
/** Build the lanes exactly as `initLanes` does: real core-set load (curated
|
|
377
|
+
* file in a temp workspace), real frecency hot set over seeded selection
|
|
378
|
+
* rows, real section index/needle/edge graph, pre-rendered prefix cards. */
|
|
379
|
+
async function buildFixtureLanes(): Promise<FixtureLanes> {
|
|
380
|
+
workspaceDir = mkdtempSync(join(tmpdir(), "carry-integration-"));
|
|
381
|
+
mkdirSync(join(workspaceDir, "memory"), { recursive: true });
|
|
382
|
+
writeFileSync(
|
|
383
|
+
join(workspaceDir, "memory", "core-pages.md"),
|
|
384
|
+
[
|
|
385
|
+
"# Core pages (maintainer-curated)",
|
|
386
|
+
"- [[core-alpha]]",
|
|
387
|
+
"- core-beta",
|
|
388
|
+
"Prose annotation lines are ignored by the loader.",
|
|
389
|
+
"",
|
|
390
|
+
].join("\n"),
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
// Seed a selections history (a PRIOR conversation) making three slugs hot,
|
|
394
|
+
// with distinct frecency so the hot order is deterministic.
|
|
395
|
+
const seed = testSqlite.query(/*sql*/ `
|
|
396
|
+
INSERT INTO memory_v3_selections
|
|
397
|
+
(conversation_id, turn, slug, source, pinned, created_at)
|
|
398
|
+
VALUES (?, ?, ?, 'needle', 0, ?)
|
|
399
|
+
`);
|
|
400
|
+
const seedCounts: Array<[Slug, number]> = [
|
|
401
|
+
["hot-one", 3],
|
|
402
|
+
["hot-two", 2],
|
|
403
|
+
["hot-three", 1],
|
|
404
|
+
];
|
|
405
|
+
for (const [slug, count] of seedCounts) {
|
|
406
|
+
for (let turn = 1; turn <= count; turn++) {
|
|
407
|
+
seed.run("conv-seed", turn, slug, BASE - 60_000);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const sectionIndex = await buildSectionIndex(
|
|
412
|
+
ALL_SLUGS,
|
|
413
|
+
async (slug) => RAW[slug]!,
|
|
414
|
+
);
|
|
415
|
+
const needle = buildSectionNeedle(sectionIndex);
|
|
416
|
+
const entries: PageIndexEntry[] = ALL_SLUGS.map((slug, i) => ({
|
|
417
|
+
id: i + 1,
|
|
418
|
+
slug,
|
|
419
|
+
summary: `summary of ${slug}`,
|
|
420
|
+
edges: [],
|
|
421
|
+
leaves: [],
|
|
422
|
+
modifiedAt: 0,
|
|
423
|
+
}));
|
|
424
|
+
const edgeGraph = await buildEdgeGraph(entries, async (slug) => RAW[slug]!);
|
|
425
|
+
|
|
426
|
+
const coreSlugs = loadCoreSet(workspaceDir).filter((slug) =>
|
|
427
|
+
sectionIndex.byArticle.has(slug),
|
|
428
|
+
);
|
|
429
|
+
const hotSlugs = computeHotSet(
|
|
430
|
+
{ db: testDb },
|
|
431
|
+
{
|
|
432
|
+
k: 3,
|
|
433
|
+
halfLifeMs: 14 * DAY_MS,
|
|
434
|
+
now: BASE,
|
|
435
|
+
excludeSlugs: new Set(coreSlugs),
|
|
436
|
+
},
|
|
437
|
+
)
|
|
438
|
+
.map((entry) => entry.slug)
|
|
439
|
+
.filter((slug) => sectionIndex.byArticle.has(slug));
|
|
440
|
+
|
|
441
|
+
const prefixCards = new Map<Slug, string>(
|
|
442
|
+
[...coreSlugs, ...hotSlugs].map((slug) => [slug, card(slug)]),
|
|
443
|
+
);
|
|
444
|
+
return { sectionIndex, needle, edgeGraph, coreSlugs, hotSlugs, prefixCards };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
// Selector provider stub: selects the scripted `keep` slugs by pool id and
|
|
449
|
+
// captures the rendered stable-prefix block (text + cache_control) per call.
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
let keep: Slug[] = [];
|
|
453
|
+
let selectCalls = 0;
|
|
454
|
+
const stablePrefixCaptures: Array<{
|
|
455
|
+
text: string;
|
|
456
|
+
cacheControl: unknown;
|
|
457
|
+
} | null> = [];
|
|
458
|
+
|
|
459
|
+
function toolUseResponse(input: Record<string, unknown>): ProviderResponse {
|
|
460
|
+
return {
|
|
461
|
+
model: "stub-model",
|
|
462
|
+
stopReason: "tool_use",
|
|
463
|
+
usage: { inputTokens: 0, outputTokens: 0 },
|
|
464
|
+
content: [{ type: "tool_use", id: "tu-1", name: "select_pages", input }],
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Parse the two-segment selector input into the globally-numbered pool slug
|
|
469
|
+
* list (same helper as the sibling integration tests). */
|
|
470
|
+
function candidateSlugs(messages: Message[]): Slug[] {
|
|
471
|
+
const entries: Array<{ id: number; slug: string }> = [];
|
|
472
|
+
for (const msg of messages) {
|
|
473
|
+
for (const block of msg.content) {
|
|
474
|
+
if (block.type !== "text") continue;
|
|
475
|
+
const cards = /<candidate_cards>\n([\s\S]*?)\n<\/candidate_cards>/.exec(
|
|
476
|
+
block.text,
|
|
477
|
+
);
|
|
478
|
+
if (cards) {
|
|
479
|
+
for (const m of cards[1].matchAll(
|
|
480
|
+
/^\[(\d+)\] # memory\/concepts\/(.+)\.md$/gm,
|
|
481
|
+
)) {
|
|
482
|
+
entries.push({ id: Number(m[1]), slug: m[2]! });
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
const finder = /<candidates>\n([\s\S]*?)\n<\/candidates>/.exec(
|
|
486
|
+
block.text,
|
|
487
|
+
);
|
|
488
|
+
if (finder) {
|
|
489
|
+
for (const line of finder[1].split("\n")) {
|
|
490
|
+
const m = /^\[(\d+)\] (\S+)(?: — |$)/.exec(line);
|
|
491
|
+
if (m) entries.push({ id: Number(m[1]), slug: m[2]! });
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return entries.sort((a, b) => a.id - b.id).map((e) => e.slug);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function makeProviderStub(): Provider {
|
|
500
|
+
return {
|
|
501
|
+
name: "stub",
|
|
502
|
+
sendMessage: async (messages) => {
|
|
503
|
+
selectCalls += 1;
|
|
504
|
+
const first = messages[0]!.content[0] as
|
|
505
|
+
| (ContentBlock & { cache_control?: unknown })
|
|
506
|
+
| undefined;
|
|
507
|
+
stablePrefixCaptures.push(
|
|
508
|
+
first && first.type === "text" && first.cache_control
|
|
509
|
+
? { text: first.text, cacheControl: first.cache_control }
|
|
510
|
+
: null,
|
|
511
|
+
);
|
|
512
|
+
const ids: number[] = [];
|
|
513
|
+
candidateSlugs(messages).forEach((slug, i) => {
|
|
514
|
+
if (keep.includes(slug)) ids.push(i + 1);
|
|
515
|
+
});
|
|
516
|
+
return toolUseResponse({ ids });
|
|
517
|
+
},
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
// Turn driver: simulates exactly what runtime assembly + the user-prompt-submit
|
|
523
|
+
// hook do around the injectors each turn (see the module doc).
|
|
524
|
+
// ---------------------------------------------------------------------------
|
|
525
|
+
|
|
526
|
+
interface TurnRecord {
|
|
527
|
+
turn: number;
|
|
528
|
+
netNewSlugs: Slug[];
|
|
529
|
+
netNewBytes: number;
|
|
530
|
+
blockText: string;
|
|
531
|
+
cardsPlacement: string;
|
|
532
|
+
spotlightText: string;
|
|
533
|
+
spotlightBytes: number;
|
|
534
|
+
spotlightEntries: number;
|
|
535
|
+
spotlightPlacement: string;
|
|
536
|
+
residentBytes: number;
|
|
537
|
+
prunedSlugs: Set<string>;
|
|
538
|
+
/** JSON of the spotlight-stripped (persistent-layer) history after the turn. */
|
|
539
|
+
snapshot: string;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function insertMessageRow(
|
|
543
|
+
convId: string,
|
|
544
|
+
id: string,
|
|
545
|
+
role: "user" | "assistant",
|
|
546
|
+
content: ContentBlock[],
|
|
547
|
+
createdAt: number,
|
|
548
|
+
): void {
|
|
549
|
+
testSqlite
|
|
550
|
+
.query(
|
|
551
|
+
/*sql*/ `
|
|
552
|
+
INSERT INTO messages (id, conversation_id, role, content, metadata, created_at)
|
|
553
|
+
VALUES (?, ?, ?, ?, NULL, ?)
|
|
554
|
+
`,
|
|
555
|
+
)
|
|
556
|
+
.run(id, convId, role, JSON.stringify(content), createdAt);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function persistentView(history: Message[]): string {
|
|
560
|
+
return JSON.stringify(stripSpotlightInjections(history));
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async function runTurn(
|
|
564
|
+
convId: string,
|
|
565
|
+
turnIndex: number,
|
|
566
|
+
query: string,
|
|
567
|
+
keepList: Slug[],
|
|
568
|
+
): Promise<TurnRecord> {
|
|
569
|
+
const history = histories.get(convId)!;
|
|
570
|
+
scriptedTurns.set(`${convId}:${turnIndex}`, query);
|
|
571
|
+
keep = keepList;
|
|
572
|
+
|
|
573
|
+
const userRowId = `${convId}-m${turnIndex}-user`;
|
|
574
|
+
const userContent: ContentBlock[] = [
|
|
575
|
+
{ type: "text", text: `user message ${turnIndex}: ${query}` },
|
|
576
|
+
];
|
|
577
|
+
insertMessageRow(
|
|
578
|
+
convId,
|
|
579
|
+
userRowId,
|
|
580
|
+
"user",
|
|
581
|
+
userContent,
|
|
582
|
+
BASE + turnIndex * 1000,
|
|
583
|
+
);
|
|
584
|
+
history.push({ role: "user", content: [...userContent] });
|
|
585
|
+
|
|
586
|
+
const ctx = {
|
|
587
|
+
requestId: `req-${turnIndex}`,
|
|
588
|
+
conversationId: convId,
|
|
589
|
+
turnIndex,
|
|
590
|
+
trust: {
|
|
591
|
+
sourceChannel: "vellum" as const,
|
|
592
|
+
trustClass: "guardian" as const,
|
|
593
|
+
},
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
const activeBefore = getActiveSlugs(convId);
|
|
597
|
+
const cards = await memoryV3Injector.produce(ctx);
|
|
598
|
+
if (!cards)
|
|
599
|
+
throw new Error(`turn ${turnIndex}: cards injector returned null`);
|
|
600
|
+
// Runtime assembly invokes the block's attachment-commit callback at its
|
|
601
|
+
// user-tail commit point — this is where the everInjected store records
|
|
602
|
+
// the turn's cards (and the prune valve is scheduled).
|
|
603
|
+
const commit = cards.meta?.[MEMORY_V3_COMMIT_META_KEY];
|
|
604
|
+
if (typeof commit === "function") (commit as () => void)();
|
|
605
|
+
const netNewSlugs = [...getActiveSlugs(convId)].filter(
|
|
606
|
+
(slug) => !activeBefore.has(slug),
|
|
607
|
+
);
|
|
608
|
+
const injected = getInjected(convId);
|
|
609
|
+
const netNewBytes = netNewSlugs.reduce(
|
|
610
|
+
(sum, slug) => sum + injected.get(slug)!.bytes,
|
|
611
|
+
0,
|
|
612
|
+
);
|
|
613
|
+
|
|
614
|
+
// Runtime assembly: a non-empty card block splices onto the CURRENT user
|
|
615
|
+
// message; the user-prompt-submit hook persists the unwrapped inner text
|
|
616
|
+
// under the v3 metadata key (assembly captures it unwrapped).
|
|
617
|
+
if (cards.text.length > 0) {
|
|
618
|
+
const tail = history[history.length - 1]!;
|
|
619
|
+
tail.content = [{ type: "text", text: cards.text }, ...tail.content];
|
|
620
|
+
testSqlite
|
|
621
|
+
.query(/*sql*/ `UPDATE messages SET metadata = ? WHERE id = ?`)
|
|
622
|
+
.run(
|
|
623
|
+
JSON.stringify({
|
|
624
|
+
[MEMORY_V3_INJECTED_BLOCK_METADATA_KEY]: unwrapMemoryBlock(
|
|
625
|
+
cards.text,
|
|
626
|
+
),
|
|
627
|
+
}),
|
|
628
|
+
userRowId,
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Spotlight: scoped strip of the stale block (real assembly helper), then
|
|
633
|
+
// append the fresh one at the current-message tail.
|
|
634
|
+
const spotlight = await memoryV3SpotlightInjector.produce(ctx);
|
|
635
|
+
const stripped = stripSpotlightInjections(history);
|
|
636
|
+
history.splice(0, history.length, ...stripped);
|
|
637
|
+
if (spotlight) {
|
|
638
|
+
const tail = history[history.length - 1]!;
|
|
639
|
+
tail.content = [...tail.content, { type: "text", text: spotlight.text }];
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const replyContent: ContentBlock[] = [
|
|
643
|
+
{ type: "text", text: `reply ${turnIndex}` },
|
|
644
|
+
];
|
|
645
|
+
insertMessageRow(
|
|
646
|
+
convId,
|
|
647
|
+
`${convId}-m${turnIndex}-assistant`,
|
|
648
|
+
"assistant",
|
|
649
|
+
replyContent,
|
|
650
|
+
BASE + turnIndex * 1000 + 500,
|
|
651
|
+
);
|
|
652
|
+
history.push({ role: "assistant", content: replyContent });
|
|
653
|
+
|
|
654
|
+
// End of turn: the deferred prune valve (scheduled by the injector) runs.
|
|
655
|
+
await flushPruneValveForTests();
|
|
656
|
+
|
|
657
|
+
return {
|
|
658
|
+
turn: turnIndex,
|
|
659
|
+
netNewSlugs,
|
|
660
|
+
netNewBytes,
|
|
661
|
+
blockText: cards.text,
|
|
662
|
+
cardsPlacement: cards.placement ?? "",
|
|
663
|
+
spotlightText: spotlight?.text ?? "",
|
|
664
|
+
spotlightBytes: spotlight ? Buffer.byteLength(spotlight.text, "utf8") : 0,
|
|
665
|
+
spotlightEntries: spotlight
|
|
666
|
+
? (spotlight.text.match(/^## memory\/concepts\//gm) ?? []).length
|
|
667
|
+
: 0,
|
|
668
|
+
spotlightPlacement: spotlight?.placement ?? "",
|
|
669
|
+
residentBytes: residentBytes(convId),
|
|
670
|
+
prunedSlugs: getPrunedSlugs(convId),
|
|
671
|
+
snapshot: persistentView(history),
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** Rebuild a conversation's history from the temp DB — mirrors the
|
|
676
|
+
* `daemon/conversation.ts` v3 rehydration splice: re-wrap the persisted
|
|
677
|
+
* metadata block, filter pruned slugs' card sections, skip an all-pruned
|
|
678
|
+
* block, and prepend onto the stored content. */
|
|
679
|
+
function rehydrateFromDb(convId: string): Message[] {
|
|
680
|
+
const rows = testSqlite
|
|
681
|
+
.query(
|
|
682
|
+
/*sql*/ `
|
|
683
|
+
SELECT role, content, metadata FROM messages
|
|
684
|
+
WHERE conversation_id = ? ORDER BY created_at ASC, rowid ASC
|
|
685
|
+
`,
|
|
686
|
+
)
|
|
687
|
+
.all(convId) as Array<{
|
|
688
|
+
role: "user" | "assistant";
|
|
689
|
+
content: string;
|
|
690
|
+
metadata: string | null;
|
|
691
|
+
}>;
|
|
692
|
+
const pruned = getPrunedSlugs(convId);
|
|
693
|
+
return rows.map((row) => {
|
|
694
|
+
let content = JSON.parse(row.content) as ContentBlock[];
|
|
695
|
+
if (row.role === "user" && row.metadata) {
|
|
696
|
+
const meta = JSON.parse(row.metadata) as Record<string, unknown>;
|
|
697
|
+
const block = meta[MEMORY_V3_INJECTED_BLOCK_METADATA_KEY];
|
|
698
|
+
if (typeof block === "string") {
|
|
699
|
+
const resident = filterPrunedCardSections(
|
|
700
|
+
unwrapMemoryBlock(block),
|
|
701
|
+
pruned,
|
|
702
|
+
);
|
|
703
|
+
if (resident.length > 0) {
|
|
704
|
+
content = [
|
|
705
|
+
{ type: "text", text: wrapMemoryBlock(resident) },
|
|
706
|
+
...content,
|
|
707
|
+
];
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
return { role: row.role, content };
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// ---------------------------------------------------------------------------
|
|
716
|
+
// The scripted 10-turn run (+ fork, + restart) executed once; tests assert on
|
|
717
|
+
// the collected artifacts.
|
|
718
|
+
// ---------------------------------------------------------------------------
|
|
719
|
+
|
|
720
|
+
const records: TurnRecord[] = [];
|
|
721
|
+
let forkRecord: TurnRecord;
|
|
722
|
+
let pruneWindow: { max: number; target: number; bytesFreedExpected: number };
|
|
723
|
+
let restartLiveJson = "";
|
|
724
|
+
let restartRehydratedJson = "";
|
|
725
|
+
|
|
726
|
+
/** Per-turn script: query terms drive the needle; `keep` is the deterministic
|
|
727
|
+
* selector output (subset of stable prefix ∪ needle hits). */
|
|
728
|
+
const SCRIPT: Array<{ query: string; keep: Slug[]; expectNetNew: Slug[] }> = [
|
|
729
|
+
// 1 — first turn: core + hot pages selected via the stable prefix, plus one
|
|
730
|
+
// finder page.
|
|
731
|
+
{
|
|
732
|
+
query: "apple",
|
|
733
|
+
keep: ["core-alpha", "core-beta", "hot-one", "page-a"],
|
|
734
|
+
expectNetNew: ["core-alpha", "core-beta", "hot-one", "page-a"],
|
|
735
|
+
},
|
|
736
|
+
// 2 — finder hit on a HOT page (raspberry) keeps its matched section.
|
|
737
|
+
{
|
|
738
|
+
query: "banana raspberry",
|
|
739
|
+
keep: ["hot-two", "page-b"],
|
|
740
|
+
expectNetNew: ["hot-two", "page-b"],
|
|
741
|
+
},
|
|
742
|
+
// 3 — ALL-REPEAT turn: every selection already resident → zero new bytes.
|
|
743
|
+
{
|
|
744
|
+
query: "apple banana",
|
|
745
|
+
keep: ["page-a", "page-b", "core-alpha"],
|
|
746
|
+
expectNetNew: [],
|
|
747
|
+
},
|
|
748
|
+
// 4 — topic shift: four fresh pages.
|
|
749
|
+
{
|
|
750
|
+
query: "cherry dragonfruit elderberry fig",
|
|
751
|
+
keep: ["page-c", "page-d", "page-e", "page-f"],
|
|
752
|
+
expectNetNew: ["page-c", "page-d", "page-e", "page-f"],
|
|
753
|
+
},
|
|
754
|
+
// 5–6 — steady accumulation.
|
|
755
|
+
{
|
|
756
|
+
query: "guava honeydew imbe",
|
|
757
|
+
keep: ["page-g", "page-h", "page-i"],
|
|
758
|
+
expectNetNew: ["page-g", "page-h", "page-i"],
|
|
759
|
+
},
|
|
760
|
+
{
|
|
761
|
+
query: "jackfruit kiwi lemon",
|
|
762
|
+
keep: ["page-j", "page-k", "page-l"],
|
|
763
|
+
expectNetNew: ["page-j", "page-k", "page-l"],
|
|
764
|
+
},
|
|
765
|
+
// 7 — the prune valve trips after this turn (window configured in beforeAll).
|
|
766
|
+
{
|
|
767
|
+
query: "mango nectarine",
|
|
768
|
+
keep: ["page-m", "page-n"],
|
|
769
|
+
expectNetNew: ["page-m", "page-n"],
|
|
770
|
+
},
|
|
771
|
+
// 8 — one more page; the restart + fork checkpoints follow this turn.
|
|
772
|
+
{ query: "olive", keep: ["page-o"], expectNetNew: ["page-o"] },
|
|
773
|
+
// 9 — a PRUNED slug re-selected re-injects as a fresh card.
|
|
774
|
+
{ query: "apple", keep: ["page-a"], expectNetNew: ["page-a"] },
|
|
775
|
+
// 10 — final all-repeat turn (steady state: fresh cost is spotlight-only).
|
|
776
|
+
{ query: "cherry", keep: ["page-c", "core-beta"], expectNetNew: [] },
|
|
777
|
+
];
|
|
778
|
+
|
|
779
|
+
beforeAll(async () => {
|
|
780
|
+
carryMockActive = true;
|
|
781
|
+
testDb = makeDb();
|
|
782
|
+
providerStub = makeProviderStub();
|
|
783
|
+
resetMemoryV3InjectorStateForTests();
|
|
784
|
+
records.length = 0;
|
|
785
|
+
stablePrefixCaptures.length = 0;
|
|
786
|
+
selectCalls = 0;
|
|
787
|
+
pruneConfig = null;
|
|
788
|
+
histories.clear();
|
|
789
|
+
scriptedTurns.clear();
|
|
790
|
+
|
|
791
|
+
lanes = await buildFixtureLanes();
|
|
792
|
+
histories.set(CONV, []);
|
|
793
|
+
|
|
794
|
+
// Turns 1–6.
|
|
795
|
+
for (let turn = 1; turn <= 6; turn++) {
|
|
796
|
+
const step = SCRIPT[turn - 1]!;
|
|
797
|
+
records.push(await runTurn(CONV, turn, step.query, step.keep));
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Open the prune window so turn 7 tips over the cap and one pass frees
|
|
801
|
+
// EXACTLY page-a + page-b (the least-recently-selected non-exempt cards;
|
|
802
|
+
// their padded leads make them outweigh turn 7's incoming cards, so
|
|
803
|
+
// target < max holds).
|
|
804
|
+
const residentAfter6 = residentBytes(CONV);
|
|
805
|
+
const incoming = cardBytes(card("page-m")) + cardBytes(card("page-n"));
|
|
806
|
+
const bytesFreedExpected =
|
|
807
|
+
cardBytes(card("page-a")) + cardBytes(card("page-b"));
|
|
808
|
+
pruneWindow = {
|
|
809
|
+
max: residentAfter6,
|
|
810
|
+
target: residentAfter6 + incoming - bytesFreedExpected,
|
|
811
|
+
bytesFreedExpected,
|
|
812
|
+
};
|
|
813
|
+
pruneConfig = {
|
|
814
|
+
maxResidentBytes: pruneWindow.max,
|
|
815
|
+
targetResidentBytes: pruneWindow.target,
|
|
816
|
+
};
|
|
817
|
+
records.push(await runTurn(CONV, 7, SCRIPT[6]!.query, SCRIPT[6]!.keep));
|
|
818
|
+
// Close the valve window so the remaining turns exercise re-injection
|
|
819
|
+
// without a second prune.
|
|
820
|
+
pruneConfig = null;
|
|
821
|
+
|
|
822
|
+
records.push(await runTurn(CONV, 8, SCRIPT[7]!.query, SCRIPT[7]!.keep));
|
|
823
|
+
|
|
824
|
+
// RESTART checkpoint: rebuild history from the DB, compare against the live
|
|
825
|
+
// persistent layer, then ADOPT the rehydrated history and reset the
|
|
826
|
+
// injectors' in-memory state (orchestration memo + spotlight ring) — a real
|
|
827
|
+
// daemon restart does both.
|
|
828
|
+
restartLiveJson = persistentView(histories.get(CONV)!);
|
|
829
|
+
const rehydrated = rehydrateFromDb(CONV);
|
|
830
|
+
restartRehydratedJson = JSON.stringify(rehydrated);
|
|
831
|
+
histories.set(CONV, rehydrated);
|
|
832
|
+
resetMemoryV3InjectorStateForTests();
|
|
833
|
+
|
|
834
|
+
// FORK checkpoint: copy the messages (metadata blocks ride along, as in a
|
|
835
|
+
// real fork) and the everInjected record (the `forkConversation()` hook),
|
|
836
|
+
// then load the fork like a fresh conversation and run one turn on it.
|
|
837
|
+
testSqlite
|
|
838
|
+
.query(
|
|
839
|
+
/*sql*/ `
|
|
840
|
+
INSERT INTO messages (id, conversation_id, role, content, metadata, created_at)
|
|
841
|
+
SELECT id || '-fork', ?, role, content, metadata, created_at
|
|
842
|
+
FROM messages WHERE conversation_id = ?
|
|
843
|
+
`,
|
|
844
|
+
)
|
|
845
|
+
.run(FORK_CONV, CONV);
|
|
846
|
+
forkEverInjected(testDb, CONV, FORK_CONV);
|
|
847
|
+
histories.set(FORK_CONV, rehydrateFromDb(FORK_CONV));
|
|
848
|
+
forkRecord = await runTurn(FORK_CONV, 9, "strawberry guava", [
|
|
849
|
+
"page-g",
|
|
850
|
+
"hot-three",
|
|
851
|
+
]);
|
|
852
|
+
|
|
853
|
+
// Turns 9–10 on the (restarted) parent.
|
|
854
|
+
records.push(await runTurn(CONV, 9, SCRIPT[8]!.query, SCRIPT[8]!.keep));
|
|
855
|
+
records.push(await runTurn(CONV, 10, SCRIPT[9]!.query, SCRIPT[9]!.keep));
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
afterAll(async () => {
|
|
859
|
+
await flushPruneValveForTests();
|
|
860
|
+
carryMockActive = false;
|
|
861
|
+
if (workspaceDir) rmSync(workspaceDir, { recursive: true, force: true });
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
// ---------------------------------------------------------------------------
|
|
865
|
+
// Contract assertions.
|
|
866
|
+
// ---------------------------------------------------------------------------
|
|
867
|
+
|
|
868
|
+
describe("memory-v3 carry integration — cache contract", () => {
|
|
869
|
+
test("lanes: curated core in file order; frecency hot set in score order", () => {
|
|
870
|
+
expect(lanes.coreSlugs).toEqual(CORE_SLUGS);
|
|
871
|
+
expect(lanes.hotSlugs).toEqual(HOT_SLUGS);
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
test("selector stable prefix is byte-identical across all 10 turns and carries the cache breakpoint", () => {
|
|
875
|
+
// 10 parent turns + 1 fork turn, one selector call each.
|
|
876
|
+
expect(selectCalls).toBe(11);
|
|
877
|
+
expect(stablePrefixCaptures).toHaveLength(11);
|
|
878
|
+
const first = stablePrefixCaptures[0];
|
|
879
|
+
expect(first).not.toBeNull();
|
|
880
|
+
expect(first!.cacheControl).toEqual({ type: "ephemeral", ttl: "1h" });
|
|
881
|
+
for (const capture of stablePrefixCaptures) {
|
|
882
|
+
expect(capture).not.toBeNull();
|
|
883
|
+
expect(capture!.text).toBe(first!.text);
|
|
884
|
+
expect(capture!.cacheControl).toEqual(first!.cacheControl);
|
|
885
|
+
}
|
|
886
|
+
// The prefix is the five core+hot FULL cards, numbered in lane order.
|
|
887
|
+
const prefixSlugs = [...CORE_SLUGS, ...HOT_SLUGS];
|
|
888
|
+
for (const [i, slug] of prefixSlugs.entries()) {
|
|
889
|
+
expect(first!.text).toContain(`[${i + 1}] ${card(slug)}`);
|
|
890
|
+
}
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
test("per-turn persistent render is net-new cards only; all-repeat turns render zero bytes", () => {
|
|
894
|
+
for (const [i, record] of records.entries()) {
|
|
895
|
+
const expected = SCRIPT[i]!.expectNetNew;
|
|
896
|
+
expect(record.cardsPlacement).toBe("after-memory-prefix");
|
|
897
|
+
expect(new Set(record.netNewSlugs)).toEqual(new Set(expected));
|
|
898
|
+
if (expected.length === 0) {
|
|
899
|
+
// All-repeat turn: the block is still produced (v2 suppression) but
|
|
900
|
+
// carries no bytes.
|
|
901
|
+
expect(record.blockText).toBe("");
|
|
902
|
+
expect(record.netNewBytes).toBe(0);
|
|
903
|
+
continue;
|
|
904
|
+
}
|
|
905
|
+
// The block contains exactly the net-new cards, each byte-identical to
|
|
906
|
+
// a fresh render, behind the shared read-affordance header.
|
|
907
|
+
const inner = unwrapMemoryBlock(record.blockText);
|
|
908
|
+
const parsed = parseCardSections(inner);
|
|
909
|
+
expect(parsed.preamble).toBe(V3_CARDS_INJECTION_HEADER);
|
|
910
|
+
expect(new Set(parsed.sections.map((s) => s.slug))).toEqual(
|
|
911
|
+
new Set(expected),
|
|
912
|
+
);
|
|
913
|
+
for (const section of parsed.sections) {
|
|
914
|
+
expect(section.text).toBe(card(section.slug));
|
|
915
|
+
}
|
|
916
|
+
expect(record.netNewBytes).toBe(
|
|
917
|
+
expected.reduce((sum, slug) => sum + cardBytes(card(slug)), 0),
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
test("turn 1's block is byte-exact in selection order (stable prefix first, then finder)", () => {
|
|
923
|
+
expect(records[0]!.blockText).toBe(
|
|
924
|
+
wrapMemoryBlock(
|
|
925
|
+
renderCardsBlockInner([
|
|
926
|
+
card("core-alpha"),
|
|
927
|
+
card("core-beta"),
|
|
928
|
+
card("hot-one"),
|
|
929
|
+
card("page-a"),
|
|
930
|
+
]),
|
|
931
|
+
),
|
|
932
|
+
);
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
test("prior turns' blocks stay frozen in history (byte-prefix), except the prune valve's one amortized strip", () => {
|
|
936
|
+
// Pre-prune (turns 1–6): each snapshot is a byte-prefix of the next.
|
|
937
|
+
for (let i = 0; i < 5; i++) {
|
|
938
|
+
expect(
|
|
939
|
+
records[i + 1]!.snapshot.startsWith(records[i]!.snapshot.slice(0, -1)),
|
|
940
|
+
).toBe(true);
|
|
941
|
+
}
|
|
942
|
+
// Post-prune (turns 8–10, spanning the restart): frozen again.
|
|
943
|
+
for (let i = 7; i < 9; i++) {
|
|
944
|
+
expect(
|
|
945
|
+
records[i + 1]!.snapshot.startsWith(records[i]!.snapshot.slice(0, -1)),
|
|
946
|
+
).toBe(true);
|
|
947
|
+
}
|
|
948
|
+
// Turn 7's strip touched EXACTLY the pruned cards: turn 1's block lost
|
|
949
|
+
// page-a (byte-exact remainder), turn 2's lost page-b.
|
|
950
|
+
const after7 = JSON.parse(records[6]!.snapshot) as Message[];
|
|
951
|
+
const turn1Block = (after7[0]!.content[0] as { text: string }).text;
|
|
952
|
+
expect(turn1Block).toBe(
|
|
953
|
+
wrapMemoryBlock(
|
|
954
|
+
renderCardsBlockInner([
|
|
955
|
+
card("core-alpha"),
|
|
956
|
+
card("core-beta"),
|
|
957
|
+
card("hot-one"),
|
|
958
|
+
]),
|
|
959
|
+
),
|
|
960
|
+
);
|
|
961
|
+
const turn2Block = (after7[2]!.content[0] as { text: string }).text;
|
|
962
|
+
expect(turn2Block).toBe(
|
|
963
|
+
wrapMemoryBlock(renderCardsBlockInner([card("hot-two")])),
|
|
964
|
+
);
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
test("accounting: resident bytes equal cumulative net-new card bytes minus pruned bytes", () => {
|
|
968
|
+
let expected = 0;
|
|
969
|
+
for (const record of records) {
|
|
970
|
+
expected += record.netNewBytes;
|
|
971
|
+
if (record.turn === 7) expected -= pruneWindow.bytesFreedExpected;
|
|
972
|
+
expect(record.residentBytes).toBe(expected);
|
|
973
|
+
}
|
|
974
|
+
});
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
describe("memory-v3 carry integration — spotlight contract", () => {
|
|
978
|
+
test("spotlight is present every turn, at the user tail, bounded by n × (window + 1)", () => {
|
|
979
|
+
for (const record of records) {
|
|
980
|
+
expect(record.spotlightText.startsWith("<memory_spotlight>\n")).toBe(
|
|
981
|
+
true,
|
|
982
|
+
);
|
|
983
|
+
expect(record.spotlightText.endsWith("\n</memory_spotlight>")).toBe(true);
|
|
984
|
+
expect(record.spotlightPlacement).toBe("append-user-tail");
|
|
985
|
+
expect(record.spotlightEntries).toBeGreaterThanOrEqual(1);
|
|
986
|
+
expect(record.spotlightEntries).toBeLessThanOrEqual(
|
|
987
|
+
SPOTLIGHT_N * (SPOTLIGHT_WINDOW_TURNS + 1),
|
|
988
|
+
);
|
|
989
|
+
}
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
test("a finder hit on a stable-prefix (hot) page spotlights its matched section", () => {
|
|
993
|
+
expect(records[1]!.spotlightText).toContain(
|
|
994
|
+
"## memory/concepts/hot-two.md § ",
|
|
995
|
+
);
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
test("spotlight entries age out of the window instead of accumulating", () => {
|
|
999
|
+
// Turn 6's window covers turns 4–6 only: turn 1–3 entries (e.g. page-a)
|
|
1000
|
+
// are gone.
|
|
1001
|
+
expect(records[5]!.spotlightText).not.toContain("page-a.md");
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
test("the spotlight never reaches the persistent layer (blocks, metadata, history)", () => {
|
|
1005
|
+
for (const record of records) {
|
|
1006
|
+
expect(record.blockText).not.toContain("<memory_spotlight>");
|
|
1007
|
+
// The persistent-layer snapshot is spotlight-stripped by construction;
|
|
1008
|
+
// the raw live history must carry exactly ONE spotlight block (the
|
|
1009
|
+
// current turn's).
|
|
1010
|
+
}
|
|
1011
|
+
const metadataRows = testSqlite
|
|
1012
|
+
.query(
|
|
1013
|
+
/*sql*/ `
|
|
1014
|
+
SELECT metadata FROM messages
|
|
1015
|
+
WHERE conversation_id = ? AND metadata IS NOT NULL
|
|
1016
|
+
`,
|
|
1017
|
+
)
|
|
1018
|
+
.all(CONV) as Array<{ metadata: string }>;
|
|
1019
|
+
expect(metadataRows.length).toBeGreaterThan(0);
|
|
1020
|
+
for (const row of metadataRows) {
|
|
1021
|
+
expect(row.metadata).not.toContain("memory_spotlight");
|
|
1022
|
+
}
|
|
1023
|
+
const liveSpotlights = histories
|
|
1024
|
+
.get(CONV)!
|
|
1025
|
+
.flatMap((m) => m.content)
|
|
1026
|
+
.filter(
|
|
1027
|
+
(b): b is { type: "text"; text: string } =>
|
|
1028
|
+
b.type === "text" && b.text.startsWith("<memory_spotlight>\n"),
|
|
1029
|
+
);
|
|
1030
|
+
expect(liveSpotlights).toHaveLength(1);
|
|
1031
|
+
});
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
describe("memory-v3 carry integration — prune contract", () => {
|
|
1035
|
+
test("the valve trips at turn 7: resident drops to target, core/hot survive", () => {
|
|
1036
|
+
// Nothing pruned through turn 6.
|
|
1037
|
+
for (let i = 0; i < 6; i++) {
|
|
1038
|
+
expect(records[i]!.prunedSlugs.size).toBe(0);
|
|
1039
|
+
}
|
|
1040
|
+
// Turn 7: exactly the two least-recently-selected non-exempt cards.
|
|
1041
|
+
expect(records[6]!.prunedSlugs).toEqual(new Set(["page-a", "page-b"]));
|
|
1042
|
+
expect(records[6]!.residentBytes).toBe(pruneWindow.target);
|
|
1043
|
+
expect(records[6]!.residentBytes).toBeLessThanOrEqual(pruneWindow.max);
|
|
1044
|
+
// Core and hot lane members were exempt and remain active.
|
|
1045
|
+
const activeAfter7 = getActiveSlugs(CONV);
|
|
1046
|
+
for (const slug of [...CORE_SLUGS, ...HOT_SLUGS.slice(0, 2)]) {
|
|
1047
|
+
expect(activeAfter7.has(slug)).toBe(true);
|
|
1048
|
+
}
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
test("a pruned slug re-selected at turn 9 re-injects as a fresh card", () => {
|
|
1052
|
+
const turn9 = records[8]!;
|
|
1053
|
+
expect(turn9.netNewSlugs).toEqual(["page-a"]);
|
|
1054
|
+
expect(turn9.blockText).toBe(
|
|
1055
|
+
wrapMemoryBlock(renderCardsBlockInner([card("page-a")])),
|
|
1056
|
+
);
|
|
1057
|
+
// page-a is active again; page-b stays pruned.
|
|
1058
|
+
expect(turn9.prunedSlugs).toEqual(new Set(["page-b"]));
|
|
1059
|
+
expect(getActiveSlugs(CONV).has("page-a")).toBe(true);
|
|
1060
|
+
});
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
describe("memory-v3 carry integration — restart contract", () => {
|
|
1064
|
+
test("rehydrating from the DB reproduces the live persistent layer byte-identically", () => {
|
|
1065
|
+
expect(restartRehydratedJson).toBe(restartLiveJson);
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
test("pruned sections are absent from the rehydrated history (metadata stays intact)", () => {
|
|
1069
|
+
const rehydrated = JSON.parse(restartRehydratedJson) as Message[];
|
|
1070
|
+
const allText = rehydrated
|
|
1071
|
+
.flatMap((m) => m.content)
|
|
1072
|
+
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
1073
|
+
.map((b) => b.text)
|
|
1074
|
+
.join("\n");
|
|
1075
|
+
expect(allText).not.toContain("# memory/concepts/page-a.md");
|
|
1076
|
+
expect(allText).not.toContain("# memory/concepts/page-b.md");
|
|
1077
|
+
// Resident cards survive the round trip…
|
|
1078
|
+
expect(allText).toContain(card("page-c"));
|
|
1079
|
+
// …and the persisted metadata still carries the pruned cards (the filter
|
|
1080
|
+
// is rehydration-time, never a metadata rewrite).
|
|
1081
|
+
const metadata = testSqlite
|
|
1082
|
+
.query(
|
|
1083
|
+
/*sql*/ `
|
|
1084
|
+
SELECT metadata FROM messages
|
|
1085
|
+
WHERE conversation_id = ? AND id = ?
|
|
1086
|
+
`,
|
|
1087
|
+
)
|
|
1088
|
+
.get(CONV, `${CONV}-m1-user`) as { metadata: string };
|
|
1089
|
+
expect(metadata.metadata).toContain("# memory/concepts/page-a.md");
|
|
1090
|
+
});
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
describe("memory-v3 carry integration — fork contract", () => {
|
|
1094
|
+
test("a fork inherits the dedup record and renders no duplicate cards for inherited slugs", () => {
|
|
1095
|
+
// page-g was injected on the parent before the fork; hot-three never was.
|
|
1096
|
+
expect(forkRecord.netNewSlugs).toEqual(["hot-three"]);
|
|
1097
|
+
expect(forkRecord.blockText).toBe(
|
|
1098
|
+
wrapMemoryBlock(renderCardsBlockInner([card("hot-three")])),
|
|
1099
|
+
);
|
|
1100
|
+
// Inherited active and pruned state both copied (full-fork semantics).
|
|
1101
|
+
const forkActive = getActiveSlugs(FORK_CONV);
|
|
1102
|
+
expect(forkActive.has("page-g")).toBe(true);
|
|
1103
|
+
expect(forkActive.has("core-alpha")).toBe(true);
|
|
1104
|
+
expect(getPrunedSlugs(FORK_CONV)).toEqual(new Set(["page-a", "page-b"]));
|
|
1105
|
+
// The fork's rehydrated history carries the inherited page-g card exactly
|
|
1106
|
+
// once (from the copied metadata), not a re-render.
|
|
1107
|
+
const forkText = histories
|
|
1108
|
+
.get(FORK_CONV)!
|
|
1109
|
+
.flatMap((m) => m.content)
|
|
1110
|
+
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
1111
|
+
.map((b) => b.text)
|
|
1112
|
+
.join("\n");
|
|
1113
|
+
expect(forkText.split(card("page-g")).length - 1).toBe(1);
|
|
1114
|
+
});
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
describe("memory-v3 carry integration — footprint gate", () => {
|
|
1118
|
+
test("steady-state per-turn fresh cost is net-new + spotlight, not the working set", () => {
|
|
1119
|
+
// The all-repeat turns pay ZERO persistent bytes while the resident
|
|
1120
|
+
// working set stays in the thousands — the cache win the rework ships.
|
|
1121
|
+
const turn3 = records[2]!;
|
|
1122
|
+
const turn10 = records[9]!;
|
|
1123
|
+
expect(turn3.netNewBytes).toBe(0);
|
|
1124
|
+
expect(turn10.netNewBytes).toBe(0);
|
|
1125
|
+
expect(turn10.spotlightBytes).toBeGreaterThan(0);
|
|
1126
|
+
// The resident working set holds the whole accumulated card footprint
|
|
1127
|
+
// (15+ active pages here) while the turn pays only the spotlight.
|
|
1128
|
+
expect(getActiveSlugs(CONV).size).toBeGreaterThanOrEqual(15);
|
|
1129
|
+
expect(turn10.netNewBytes + turn10.spotlightBytes).toBeLessThan(
|
|
1130
|
+
turn10.residentBytes,
|
|
1131
|
+
);
|
|
1132
|
+
|
|
1133
|
+
// The measured footprint table — the PR-body cutover-gate artifact.
|
|
1134
|
+
// Fresh (uncached) per-turn cost = net-new + spotlight; resident = the
|
|
1135
|
+
// accumulated frozen-card footprint riding the provider prefix cache.
|
|
1136
|
+
const lines = [
|
|
1137
|
+
"| turn | net-new cards | net-new bytes | spotlight bytes | fresh bytes (net-new + spotlight) | resident bytes | note |",
|
|
1138
|
+
"|---|---|---|---|---|---|---|",
|
|
1139
|
+
...records.map((r) => {
|
|
1140
|
+
const note =
|
|
1141
|
+
r.turn === 3
|
|
1142
|
+
? "all-repeat turn"
|
|
1143
|
+
: r.turn === 7
|
|
1144
|
+
? `prune valve fired (−${pruneWindow.bytesFreedExpected}B)`
|
|
1145
|
+
: r.turn === 9
|
|
1146
|
+
? "pruned slug re-injected"
|
|
1147
|
+
: r.turn === 10
|
|
1148
|
+
? "all-repeat (steady state)"
|
|
1149
|
+
: "";
|
|
1150
|
+
return `| ${r.turn} | ${r.netNewSlugs.length} | ${r.netNewBytes} | ${r.spotlightBytes} | ${r.netNewBytes + r.spotlightBytes} | ${r.residentBytes} | ${note} |`;
|
|
1151
|
+
}),
|
|
1152
|
+
];
|
|
1153
|
+
console.log(
|
|
1154
|
+
`\nmemory-v3 carry footprint (measured):\n${lines.join("\n")}\n`,
|
|
1155
|
+
);
|
|
1156
|
+
});
|
|
1157
|
+
});
|