@vellumai/assistant 0.6.3 → 0.6.4
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 +273 -10
- package/Dockerfile +2 -3
- package/bun.lock +5 -13
- package/docs/backup-troubleshooting.md +52 -0
- package/docs/browser-use-architecture-phase2.md +174 -0
- package/docs/stt-provider-onboarding.md +120 -0
- package/knip.json +12 -2
- package/node_modules/@vellumai/ces-contracts/bun.lock +8 -6
- package/node_modules/@vellumai/ces-contracts/package.json +3 -3
- package/openapi.yaml +982 -72
- package/package.json +4 -6
- package/scripts/generate-openapi.ts +0 -1
- package/scripts/test.sh +73 -18
- package/src/__tests__/agent-image-optimize.test.ts +28 -0
- package/src/__tests__/agent-loop.test.ts +123 -0
- package/src/__tests__/anthropic-provider.test.ts +263 -10
- package/src/__tests__/auto-analysis-end-to-end.test.ts +550 -0
- package/src/__tests__/auto-analysis-prompt.test.ts +50 -0
- package/src/__tests__/browser-fill-credential.test.ts +11 -0
- package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +2 -2
- package/src/__tests__/browser-skill-endstate.test.ts +31 -7
- package/src/__tests__/btw-routes.test.ts +7 -0
- package/src/__tests__/call-controller.test.ts +581 -20
- package/src/__tests__/catalog-files.test.ts +138 -0
- package/src/__tests__/channel-invite-transport.test.ts +2 -2
- package/src/__tests__/channel-readiness-routes.test.ts +16 -20
- package/src/__tests__/channel-readiness-service.test.ts +12 -7
- package/src/__tests__/checker.test.ts +157 -10
- package/src/__tests__/clawhub-files.test.ts +347 -0
- package/src/__tests__/commit-message-enrichment-service.test.ts +36 -19
- package/src/__tests__/config-analysis.test.ts +100 -0
- package/src/__tests__/config-schema.test.ts +1013 -66
- package/src/__tests__/config-watcher-cleanup-throttle.test.ts +339 -0
- package/src/__tests__/config-watcher.test.ts +43 -8
- package/src/__tests__/contact-store-user-file.test.ts +512 -0
- package/src/__tests__/contacts-write.test.ts +197 -0
- package/src/__tests__/context-window-manager.test.ts +88 -0
- package/src/__tests__/conversation-abort-tool-results.test.ts +2 -0
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -0
- package/src/__tests__/conversation-agent-loop.test.ts +98 -2
- package/src/__tests__/conversation-confirmation-signals.test.ts +135 -0
- package/src/__tests__/conversation-error.test.ts +70 -0
- package/src/__tests__/conversation-history-web-search.test.ts +11 -4
- package/src/__tests__/conversation-init.benchmark.test.ts +6 -1
- package/src/__tests__/conversation-launcher-skill-regression.test.ts +51 -0
- package/src/__tests__/conversation-list-source.test.ts +145 -0
- package/src/__tests__/conversation-pre-run-repair.test.ts +2 -0
- package/src/__tests__/conversation-provider-retry-repair.test.ts +2 -0
- package/src/__tests__/conversation-queue.test.ts +901 -60
- package/src/__tests__/conversation-routes-disk-view.test.ts +270 -0
- package/src/__tests__/conversation-runtime-assembly.test.ts +55 -0
- package/src/__tests__/conversation-skill-tools.test.ts +7 -4
- package/src/__tests__/conversation-slash-commands.test.ts +33 -0
- package/src/__tests__/conversation-slash-queue.test.ts +89 -18
- package/src/__tests__/conversation-slash-unknown.test.ts +2 -0
- package/src/__tests__/conversation-tool-setup-batch-authorized.test.ts +226 -0
- package/src/__tests__/conversation-workspace-injection.test.ts +2 -0
- package/src/__tests__/conversation-workspace-tool-tracking.test.ts +2 -0
- package/src/__tests__/credential-health-service.test.ts +352 -0
- package/src/__tests__/credential-security-invariants.test.ts +5 -3
- package/src/__tests__/credential-vault-unit.test.ts +379 -3
- package/src/__tests__/credentials-cli.test.ts +40 -16
- package/src/__tests__/cross-provider-web-search.test.ts +146 -35
- package/src/__tests__/deterministic-verification-control-plane.test.ts +10 -1
- package/src/__tests__/device-id.test.ts +112 -0
- package/src/__tests__/docker-signing-key-bootstrap.test.ts +167 -4
- package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +1 -3
- package/src/__tests__/email-html-renderer.test.ts +71 -0
- package/src/__tests__/email-invite-adapter.test.ts +36 -32
- package/src/__tests__/emit-event-signal.test.ts +71 -0
- package/src/__tests__/extension-id-sync-guard.test.ts +75 -8
- package/src/__tests__/fixtures/mock-chrome-extension.ts +11 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +206 -1
- package/src/__tests__/gateway-only-guard.test.ts +0 -1
- package/src/__tests__/gemini-provider.test.ts +64 -0
- package/src/__tests__/get-skill-detail-audit.test.ts +325 -0
- package/src/__tests__/gmail-archive-fallback.test.ts +193 -0
- package/src/__tests__/gmail-archive-gate.test.ts +246 -0
- package/src/__tests__/gmail-preferences.test.ts +117 -0
- package/src/__tests__/headless-browser-interactions.test.ts +43 -0
- package/src/__tests__/headless-browser-mode.test.ts +614 -0
- package/src/__tests__/headless-browser-navigate.test.ts +142 -5
- package/src/__tests__/headless-browser-read-tools.test.ts +11 -0
- package/src/__tests__/headless-browser-snapshot.test.ts +10 -0
- package/src/__tests__/heartbeat-service.test.ts +70 -17
- package/src/__tests__/home-state-routes.test.ts +162 -0
- package/src/__tests__/host-bash-proxy.test.ts +0 -5
- package/src/__tests__/host-browser-e2e-cloud.test.ts +138 -4
- package/src/__tests__/host-browser-e2e-self-hosted.test.ts +4 -4
- package/src/__tests__/host-browser-ws-events-e2e.test.ts +103 -0
- package/src/__tests__/host-cu-proxy.test.ts +0 -5
- package/src/__tests__/identity-intro-cache.test.ts +40 -10
- package/src/__tests__/init-feature-flag-overrides.test.ts +38 -112
- package/src/__tests__/jobs-store-upsert-debounced.test.ts +141 -0
- package/src/__tests__/llm-context-normalization.test.ts +488 -0
- package/src/__tests__/llm-context-route-provider.test.ts +86 -5
- package/src/__tests__/llm-usage-store.test.ts +363 -0
- package/src/__tests__/media-stream-output.test.ts +555 -0
- package/src/__tests__/media-stream-parser.test.ts +374 -0
- package/src/__tests__/media-stream-server-integration.test.ts +1234 -0
- package/src/__tests__/media-stream-stt-session.test.ts +588 -0
- package/src/__tests__/media-turn-detector.test.ts +440 -0
- package/src/__tests__/message-queue.test.ts +125 -0
- package/src/__tests__/migration-export-http.test.ts +6 -6
- package/src/__tests__/migration-import-commit-http.test.ts +8 -6
- package/src/__tests__/migration-import-preflight-http.test.ts +6 -5
- package/src/__tests__/migration-validate-http.test.ts +3 -3
- package/src/__tests__/mock-gateway-ipc.ts +151 -0
- package/src/__tests__/model-intents.test.ts +2 -2
- package/src/__tests__/oauth-apps-routes.test.ts +1 -0
- package/src/__tests__/oauth-cli.test.ts +2 -0
- package/src/__tests__/oauth-connect-orchestrator.test.ts +2 -0
- package/src/__tests__/oauth-provider-serializer.test.ts +1 -0
- package/src/__tests__/oauth-providers-routes.test.ts +2 -0
- package/src/__tests__/oauth-store.test.ts +85 -0
- package/src/__tests__/oauth2-gateway-transport.test.ts +249 -6
- package/src/__tests__/onboarding-template-contract.test.ts +6 -13
- package/src/__tests__/openai-provider.test.ts +176 -0
- package/src/__tests__/openai-responses-cutover-guard.test.ts +184 -0
- package/src/__tests__/openai-responses-provider.test.ts +1105 -0
- package/src/__tests__/openrouter-token-estimation.test.ts +100 -0
- package/src/__tests__/outlook-unsubscribe.test.ts +31 -2
- package/src/__tests__/persona-resolver.test.ts +251 -0
- package/src/__tests__/platform-bash-auto-approve.test.ts +4 -0
- package/src/__tests__/platform.test.ts +92 -1
- package/src/__tests__/post-turn-tool-result-truncation.test.ts +47 -0
- package/src/__tests__/prechat-onboarding-contract.test.ts +267 -0
- package/src/__tests__/pricing.test.ts +174 -0
- package/src/__tests__/qdrant-manager.test.ts +29 -8
- package/src/__tests__/regenerate-fire-and-forget-trace.test.ts +194 -0
- package/src/__tests__/relationship-state-contract.test.ts +175 -0
- package/src/__tests__/relay-server.test.ts +423 -5
- package/src/__tests__/search-skills-unified.test.ts +118 -0
- package/src/__tests__/secret-scanner-executor.test.ts +4 -0
- package/src/__tests__/secure-keys.test.ts +107 -0
- package/src/__tests__/send-endpoint-busy.test.ts +5 -1
- package/src/__tests__/sequence-store.test.ts +1 -1
- package/src/__tests__/server-history-render.test.ts +49 -0
- package/src/__tests__/settings-routes.test.ts +201 -0
- package/src/__tests__/skill-load-feature-flag.test.ts +1 -0
- package/src/__tests__/skills-file-content-endpoint.test.ts +276 -145
- package/src/__tests__/skills-files-catalog-fallback.test.ts +381 -93
- package/src/__tests__/skills.test.ts +5 -2
- package/src/__tests__/skillssh-files.test.ts +446 -0
- package/src/__tests__/slack-block-formatting.test.ts +110 -0
- package/src/__tests__/slack-channel-config.test.ts +564 -1
- package/src/__tests__/stt-catalog-parity.test.ts +282 -0
- package/src/__tests__/stt-stream-session.test.ts +535 -0
- package/src/__tests__/system-prompt.test.ts +112 -26
- package/src/__tests__/telephony-stt-routing.test.ts +329 -0
- package/src/__tests__/terminal-tools.test.ts +18 -7
- package/src/__tests__/test-preload.ts +18 -0
- package/src/__tests__/test-support/browser-skill-harness.ts +4 -1
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +9 -5
- package/src/__tests__/tool-executor-shell-integration.test.ts +4 -0
- package/src/__tests__/tool-executor.test.ts +33 -24
- package/src/__tests__/tool-result-truncation.test.ts +36 -0
- package/src/__tests__/trust-store.test.ts +7 -1
- package/src/__tests__/trusted-contact-approval-notifier.test.ts +1 -1
- package/src/__tests__/tts-catalog-parity.test.ts +345 -0
- package/src/__tests__/twilio-routes-twiml.test.ts +512 -114
- package/src/__tests__/twilio-routes.test.ts +376 -0
- package/src/__tests__/unicode.test.ts +293 -0
- package/src/__tests__/update-bulletin-format.test.ts +59 -0
- package/src/__tests__/update-bulletin.test.ts +206 -5
- package/src/__tests__/usage-routes.test.ts +25 -4
- package/src/__tests__/user-reference.test.ts +46 -61
- package/src/__tests__/verification-control-plane-policy.test.ts +4 -0
- package/src/__tests__/voice-config-update.test.ts +403 -0
- package/src/__tests__/voice-quality.test.ts +434 -19
- package/src/__tests__/workspace-heartbeat-service.test.ts +7 -0
- package/src/__tests__/workspace-migration-033-stt-service-explicit-config.test.ts +547 -0
- package/src/__tests__/workspace-migration-034-remove-calls-voice-transcription-provider.test.ts +596 -0
- package/src/__tests__/workspace-migration-drop-user-md.test.ts +368 -0
- package/src/__tests__/workspace-migration-meets.test.ts +244 -0
- package/src/__tests__/workspace-migration-seed-device-id.test.ts +14 -20
- package/src/__tests__/workspace-policy.test.ts +2 -0
- package/src/agent/image-optimize.ts +24 -12
- package/src/agent/loop.ts +43 -3
- package/src/backup/__tests__/backup-key.test.ts +152 -0
- package/src/backup/__tests__/backup-worker.test.ts +767 -0
- package/src/backup/__tests__/list-snapshots.test.ts +87 -0
- package/src/backup/__tests__/local-writer.test.ts +218 -0
- package/src/backup/__tests__/offsite-writer.test.ts +641 -0
- package/src/backup/__tests__/paths.test.ts +300 -0
- package/src/backup/__tests__/restore.test.ts +498 -0
- package/src/backup/__tests__/snapshot-lock.test.ts +352 -0
- package/src/backup/__tests__/stream-crypt.test.ts +228 -0
- package/src/backup/backup-key.ts +137 -0
- package/src/backup/backup-worker.ts +459 -0
- package/src/backup/list-snapshots.ts +147 -0
- package/src/backup/local-writer.ts +133 -0
- package/src/backup/offsite-writer.ts +222 -0
- package/src/backup/paths.ts +226 -0
- package/src/backup/restore.ts +322 -0
- package/src/backup/snapshot-lock.ts +431 -0
- package/src/backup/stream-crypt.ts +263 -0
- package/src/bundler/package-resolver.ts +4 -0
- package/src/calls/audio-store.ts +11 -5
- package/src/calls/call-controller.ts +226 -71
- package/src/calls/call-domain.ts +9 -0
- package/src/calls/call-speech-output.ts +190 -0
- package/src/calls/call-transport.ts +77 -0
- package/src/calls/media-stream-audio-transcode.ts +173 -0
- package/src/calls/media-stream-output.ts +660 -0
- package/src/calls/media-stream-parser.ts +300 -0
- package/src/calls/media-stream-protocol.ts +166 -0
- package/src/calls/media-stream-server.ts +592 -0
- package/src/calls/media-stream-stt-session.ts +460 -0
- package/src/calls/media-turn-detector.ts +230 -0
- package/src/calls/relay-server.ts +90 -75
- package/src/calls/resolve-call-tts-provider.ts +136 -0
- package/src/calls/telephony-stt-routing.ts +145 -0
- package/src/calls/tts-call-strategy.ts +161 -0
- package/src/calls/tts-text-sanitizer.ts +32 -16
- package/src/calls/twilio-routes.ts +281 -17
- package/src/calls/voice-quality.ts +78 -35
- package/src/calls/voice-session-bridge.ts +8 -1
- package/src/channels/types.ts +16 -0
- package/src/cli/__tests__/run-assistant-command.ts +11 -1
- package/src/cli/commands/__tests__/backup.test.ts +1165 -0
- package/src/cli/commands/__tests__/domain-register.test.ts +234 -0
- package/src/cli/commands/__tests__/domain-status.test.ts +132 -0
- package/src/cli/commands/__tests__/email-attachment.test.ts +422 -0
- package/src/cli/commands/__tests__/email-download.test.ts +16 -1
- package/src/cli/commands/__tests__/email-list.test.ts +22 -4
- package/src/cli/commands/__tests__/email-register.test.ts +4 -4
- package/src/cli/commands/__tests__/email-send.test.ts +37 -4
- package/src/cli/commands/__tests__/email-status.test.ts +5 -1
- package/src/cli/commands/__tests__/email-unregister.test.ts +34 -5
- package/src/cli/commands/backup.ts +993 -0
- package/src/cli/commands/conversations.ts +77 -0
- package/src/cli/commands/credentials.ts +0 -1
- package/src/cli/commands/domain.ts +210 -0
- package/src/cli/commands/email.ts +255 -3
- package/src/cli/commands/oauth/__tests__/connect.test.ts +12 -0
- package/src/cli/commands/oauth/__tests__/providers-delete.test.ts +1 -0
- package/src/cli/commands/oauth/__tests__/providers-register.test.ts +1 -0
- package/src/cli/commands/oauth/__tests__/providers-update.test.ts +1 -0
- package/src/cli/commands/oauth/mode.ts +12 -3
- package/src/cli/commands/oauth/providers.ts +15 -0
- package/src/cli/commands/oauth/shared.ts +2 -1
- package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +4 -9
- package/src/cli/commands/platform/__tests__/connect.test.ts +6 -0
- package/src/cli/commands/platform/__tests__/disconnect.test.ts +7 -1
- package/src/cli/commands/platform/__tests__/status.test.ts +6 -0
- package/src/cli/program.ts +30 -4
- package/src/config/__tests__/backup-schema.test.ts +134 -0
- package/src/config/assistant-feature-flags.ts +61 -62
- package/src/config/bundled-skills/app-builder/references/CUSTOM_ROUTES.md +37 -1
- package/src/config/bundled-skills/browser/SKILL.md +30 -5
- package/src/config/bundled-skills/browser/TOOLS.json +123 -0
- package/src/config/bundled-skills/browser/tools/browser-attach.ts +12 -0
- package/src/config/bundled-skills/browser/tools/browser-detach.ts +12 -0
- package/src/config/bundled-skills/browser/tools/browser-status.ts +12 -0
- package/src/config/bundled-skills/browser/tools/browser-wait-for-download.ts +17 -0
- package/src/config/bundled-skills/contacts/SKILL.md +2 -2
- package/src/config/bundled-skills/gmail/SKILL.md +53 -7
- package/src/config/bundled-skills/gmail/TOOLS.json +33 -3
- package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +116 -9
- package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +138 -11
- package/src/config/bundled-skills/gmail/tools/gmail-preferences-tool.ts +59 -0
- package/src/config/bundled-skills/gmail/tools/gmail-preferences.ts +82 -0
- package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +113 -17
- package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +2 -2
- package/src/config/bundled-skills/media-processing/SKILL.md +3 -9
- package/src/config/bundled-skills/media-processing/TOOLS.json +1 -6
- package/src/config/bundled-skills/media-processing/__tests__/audio-transcribe.test.ts +125 -0
- package/src/config/bundled-skills/media-processing/__tests__/extract-keyframes.test.ts +181 -0
- package/src/config/bundled-skills/media-processing/__tests__/preprocess-audio.test.ts +141 -0
- package/src/config/bundled-skills/media-processing/services/audio-transcribe.ts +32 -87
- package/src/config/bundled-skills/media-processing/services/preprocess.ts +8 -4
- package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +0 -10
- package/src/config/bundled-skills/messaging/SKILL.md +3 -3
- package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +2 -2
- package/src/config/bundled-skills/outlook/SKILL.md +2 -2
- package/src/config/bundled-skills/outlook/tools/outlook-unsubscribe.ts +2 -2
- package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
- package/src/config/bundled-skills/phone-calls/references/CONFIG.md +27 -18
- package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +3 -3
- package/src/config/bundled-skills/settings/TOOLS.json +3 -3
- package/src/config/bundled-skills/settings/tools/voice-config-update.ts +26 -22
- package/src/config/bundled-skills/slack/SKILL.md +1 -0
- package/src/config/bundled-skills/transcribe/SKILL.md +9 -14
- package/src/config/bundled-skills/transcribe/TOOLS.json +2 -7
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.test.ts +256 -0
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +38 -188
- package/src/config/bundled-tool-registry.ts +8 -0
- package/src/config/env-registry.ts +24 -0
- package/src/config/env.ts +34 -10
- package/src/config/feature-flag-registry.json +46 -14
- package/src/config/loader.ts +26 -12
- package/src/config/schema.ts +35 -10
- package/src/config/schemas/__tests__/stt.test.ts +43 -0
- package/src/config/schemas/analysis.ts +51 -0
- package/src/config/schemas/backup.ts +72 -0
- package/src/config/schemas/calls.ts +1 -26
- package/src/config/schemas/elevenlabs.ts +0 -59
- package/src/config/schemas/filing.ts +47 -7
- package/src/config/schemas/heartbeat.ts +27 -5
- package/src/config/schemas/host-browser.ts +47 -1
- package/src/config/schemas/inference.ts +1 -1
- package/src/config/schemas/memory-lifecycle.ts +14 -2
- package/src/config/schemas/services.ts +44 -0
- package/src/config/schemas/stt.ts +59 -0
- package/src/config/schemas/tts.ts +230 -0
- package/src/config/schemas/updates.ts +14 -0
- package/src/config/skills.ts +4 -0
- package/src/config/types.ts +4 -0
- package/src/contacts/contact-store.ts +56 -11
- package/src/contacts/contacts-write.ts +38 -1
- package/src/context/post-turn-tool-result-truncation.ts +3 -2
- package/src/context/tool-result-truncation.ts +2 -1
- package/src/context/window-manager.ts +45 -12
- package/src/credential-execution/executable-discovery.ts +12 -2
- package/src/credential-execution/process-manager.ts +33 -2
- package/src/credential-health/credential-health-service.ts +366 -0
- package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +324 -0
- package/src/daemon/__tests__/conversation-surfaces-launch.test.ts +497 -0
- package/src/daemon/__tests__/conversation-tool-setup.test.ts +17 -8
- package/src/daemon/__tests__/lifecycle-startup-ordering.test.ts +127 -0
- package/src/daemon/config-watcher.ts +99 -5
- package/src/daemon/conversation-agent-loop-handlers.ts +6 -0
- package/src/daemon/conversation-agent-loop.ts +101 -24
- package/src/daemon/conversation-error.ts +11 -0
- package/src/daemon/conversation-history.ts +40 -6
- package/src/daemon/conversation-launch.ts +220 -0
- package/src/daemon/conversation-lifecycle.ts +59 -9
- package/src/daemon/conversation-messaging.ts +37 -3
- package/src/daemon/conversation-notifiers.ts +5 -0
- package/src/daemon/conversation-process.ts +581 -19
- package/src/daemon/conversation-queue-manager.ts +24 -0
- package/src/daemon/conversation-runtime-assembly.ts +11 -1
- package/src/daemon/conversation-slash.ts +36 -0
- package/src/daemon/conversation-surfaces.ts +94 -4
- package/src/daemon/conversation-tool-setup.ts +25 -0
- package/src/daemon/conversation-usage.ts +7 -4
- package/src/daemon/conversation.ts +86 -28
- package/src/daemon/handlers/config-slack-channel.ts +269 -94
- package/src/daemon/handlers/conversations.ts +4 -1
- package/src/daemon/handlers/shared.ts +22 -0
- package/src/daemon/handlers/skills.ts +321 -77
- package/src/daemon/host-browser-proxy.ts +2 -1
- package/src/daemon/lifecycle.ts +122 -25
- package/src/daemon/message-protocol.ts +6 -0
- package/src/daemon/message-types/conversations.ts +34 -1
- package/src/daemon/message-types/home.ts +40 -0
- package/src/daemon/message-types/meet.ts +143 -0
- package/src/daemon/message-types/messages.ts +14 -0
- package/src/daemon/message-types/schedules.ts +34 -2
- package/src/daemon/message-types/skills.ts +16 -0
- package/src/daemon/message-types/surfaces.ts +2 -0
- package/src/daemon/server.ts +347 -2
- package/src/daemon/shutdown-handlers.ts +32 -4
- package/src/daemon/shutdown-registry.ts +40 -0
- package/src/daemon/tool-side-effects.ts +9 -0
- package/src/email/html-renderer.ts +76 -0
- package/src/heartbeat/heartbeat-service.ts +93 -7
- package/src/home/__tests__/assistant-feed-authoring.test.ts +156 -0
- package/src/home/__tests__/emit-feed-event.test.ts +169 -0
- package/src/home/__tests__/feed-scheduler.test.ts +194 -0
- package/src/home/__tests__/feed-types.test.ts +275 -0
- package/src/home/__tests__/feed-writer.test.ts +688 -0
- package/src/home/__tests__/phase5-exit-criteria.test.ts +212 -0
- package/src/home/__tests__/platform-gmail-digest.test.ts +222 -0
- package/src/home/__tests__/progress-formula.test.ts +213 -0
- package/src/home/__tests__/relationship-state-writer.test.ts +740 -0
- package/src/home/__tests__/rollup-producer.test.ts +398 -0
- package/src/home/assistant-feed-authoring.ts +124 -0
- package/src/home/emit-feed-event.ts +158 -0
- package/src/home/feed-scheduler.ts +247 -0
- package/src/home/feed-types.ts +181 -0
- package/src/home/feed-writer.ts +469 -0
- package/src/home/platform-gmail-digest.ts +163 -0
- package/src/home/progress-formula.ts +86 -0
- package/src/home/relationship-state-writer.ts +824 -0
- package/src/home/relationship-state.ts +143 -0
- package/src/home/rollup-producer.ts +384 -0
- package/src/hooks/runner.ts +7 -0
- package/src/inbound/platform-callback-registration.ts +12 -3
- package/src/inbound/public-ingress-urls.ts +12 -0
- package/src/instrument.ts +1 -1
- package/src/ipc/__tests__/cli-ipc.test.ts +200 -0
- package/src/ipc/cli-client.ts +151 -0
- package/src/ipc/cli-server.ts +234 -0
- package/src/ipc/gateway-client.ts +180 -0
- package/src/ipc/routes/index.ts +5 -0
- package/src/ipc/routes/wake-conversation.ts +19 -0
- package/src/memory/__tests__/auto-analysis-enqueue.test.ts +356 -0
- package/src/memory/__tests__/auto-analysis-guard.test.ts +57 -0
- package/src/memory/__tests__/conversation-analyze-job.test.ts +232 -0
- package/src/memory/__tests__/find-analysis-conversation.test.ts +196 -0
- package/src/memory/app-store.ts +1 -1
- package/src/memory/attachments-store.ts +70 -0
- package/src/memory/auto-analysis-enqueue.ts +127 -0
- package/src/memory/auto-analysis-guard.ts +27 -0
- package/src/memory/cleanup-schedule-state.ts +37 -0
- package/src/memory/conversation-analyze-job.ts +73 -0
- package/src/memory/conversation-crud.ts +99 -0
- package/src/memory/conversation-disk-view.ts +7 -0
- package/src/memory/conversation-group-migration.ts +34 -2
- package/src/memory/conversation-queries.ts +6 -5
- package/src/memory/db-init.ts +6 -0
- package/src/memory/db-maintenance.ts +108 -0
- package/src/memory/db.ts +1 -0
- package/src/memory/graph/conversation-graph-memory.ts +15 -0
- package/src/memory/graph/extraction.test.ts +23 -0
- package/src/memory/graph/extraction.ts +8 -0
- package/src/memory/graph/retriever.ts +27 -18
- package/src/memory/graph/scoring.test.ts +186 -0
- package/src/memory/graph/scoring.ts +31 -1
- package/src/memory/graph/tools.ts +1 -1
- package/src/memory/group-crud.ts +6 -1
- package/src/memory/indexer.ts +95 -16
- package/src/memory/job-handlers/cleanup.ts +11 -8
- package/src/memory/job-handlers/conversation-starters.ts +16 -10
- package/src/memory/jobs-store.ts +64 -4
- package/src/memory/jobs-worker.ts +22 -9
- package/src/memory/llm-usage-store.ts +92 -56
- package/src/memory/migrations/219-oauth-providers-token-exchange-body-format.ts +15 -0
- package/src/memory/migrations/220-normalize-user-file-by-principal.ts +190 -0
- package/src/memory/migrations/221-conversations-archived-at.ts +16 -0
- package/src/memory/migrations/index.ts +6 -0
- package/src/memory/migrations/registry.ts +8 -0
- package/src/memory/qdrant-manager.ts +43 -16
- package/src/memory/schema/conversations.ts +2 -0
- package/src/memory/schema/oauth.ts +3 -0
- package/src/memory/usage-buckets.ts +396 -0
- package/src/messaging/providers/gmail/client.ts +57 -6
- package/src/messaging/providers/slack/__tests__/adapter-token-routing.test.ts +282 -0
- package/src/messaging/providers/slack/adapter.ts +143 -38
- package/src/messaging/providers/slack/client.ts +16 -0
- package/src/messaging/providers/slack/types.ts +4 -0
- package/src/notifications/decision-engine.ts +3 -3
- package/src/notifications/signal.ts +5 -0
- package/src/oauth/__tests__/identity-verifier.test.ts +1 -0
- package/src/oauth/byo-connection.test.ts +18 -1
- package/src/oauth/byo-connection.ts +3 -1
- package/src/oauth/connect-orchestrator.ts +2 -0
- package/src/oauth/connection-resolver.ts +6 -2
- package/src/oauth/connection.ts +2 -0
- package/src/oauth/oauth-store.ts +9 -0
- package/src/oauth/platform-connection.test.ts +98 -0
- package/src/oauth/platform-connection.ts +52 -31
- package/src/oauth/seed-providers.ts +7 -0
- package/src/permissions/checker.ts +16 -6
- package/src/permissions/defaults.ts +49 -1
- package/src/permissions/trust-store.ts +3 -3
- package/src/permissions/workspace-policy.ts +3 -0
- package/src/platform/client.test.ts +10 -0
- package/src/platform/sync-identity.ts +129 -0
- package/src/prompts/persona-resolver.ts +126 -2
- package/src/prompts/system-prompt.ts +59 -18
- package/src/prompts/templates/BOOTSTRAP.md +5 -5
- package/src/prompts/templates/SOUL.md +3 -1
- package/src/prompts/templates/UPDATES.md +12 -0
- package/src/prompts/templates/channels/slack.md +20 -0
- package/src/prompts/update-bulletin-format.ts +26 -9
- package/src/prompts/update-bulletin.ts +34 -23
- package/src/prompts/user-reference.ts +20 -17
- package/src/providers/__tests__/provider-secret-catalog.test.ts +42 -0
- package/src/providers/anthropic/client.ts +157 -61
- package/src/providers/fireworks/client.ts +2 -2
- package/src/providers/gemini/client.ts +9 -1
- package/src/providers/model-catalog.ts +6 -0
- package/src/providers/model-intents.ts +4 -4
- package/src/providers/ollama/client.ts +2 -2
- package/src/providers/openai/chat-completions-provider.ts +474 -0
- package/src/providers/openai/client.ts +25 -440
- package/src/providers/openai/responses-provider.ts +502 -0
- package/src/providers/openrouter/client.ts +101 -4
- package/src/providers/provider-secret-catalog.ts +139 -0
- package/src/providers/registry.ts +2 -2
- package/src/providers/retry.ts +14 -3
- package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +251 -0
- package/src/providers/speech-to-text/__tests__/resolve.test.ts +828 -0
- package/src/providers/speech-to-text/deepgram-realtime.test.ts +980 -0
- package/src/providers/speech-to-text/deepgram-realtime.ts +767 -0
- package/src/providers/speech-to-text/deepgram.test.ts +332 -0
- package/src/providers/speech-to-text/deepgram.ts +115 -0
- package/src/providers/speech-to-text/google-gemini-live-stream.test.ts +743 -0
- package/src/providers/speech-to-text/google-gemini-live-stream.ts +625 -0
- package/src/providers/speech-to-text/google-gemini.test.ts +226 -0
- package/src/providers/speech-to-text/google-gemini.ts +101 -0
- package/src/providers/speech-to-text/openai-whisper-stream.test.ts +564 -0
- package/src/providers/speech-to-text/openai-whisper-stream.ts +381 -0
- package/src/providers/speech-to-text/openai-whisper.test.ts +1 -37
- package/src/providers/speech-to-text/openai-whisper.ts +63 -33
- package/src/providers/speech-to-text/provider-catalog.ts +306 -0
- package/src/providers/speech-to-text/resolve.ts +386 -6
- package/src/providers/types.ts +9 -0
- package/src/runtime/AGENTS.md +43 -1
- package/src/runtime/__tests__/agent-wake.test.ts +831 -0
- package/src/runtime/__tests__/runtime-mode.test.ts +62 -0
- package/src/runtime/__tests__/slack-block-formatting.test.ts +481 -0
- package/src/runtime/agent-wake.ts +512 -0
- package/src/runtime/auth/__tests__/route-policy.test.ts +40 -0
- package/src/runtime/auth/route-policy.ts +30 -5
- package/src/runtime/auth/token-service.ts +56 -1
- package/src/runtime/btw-sidechain.ts +2 -0
- package/src/runtime/capability-tokens.ts +10 -10
- package/src/runtime/channel-invite-transport.ts +1 -1
- package/src/runtime/channel-invite-transports/email.ts +14 -6
- package/src/runtime/channel-readiness-service.ts +12 -22
- package/src/runtime/chrome-extension-registry.ts +38 -2
- package/src/runtime/http-server.ts +395 -10
- package/src/runtime/http-types.ts +6 -2
- package/src/runtime/migrations/__tests__/vbundle-import-credentials.test.ts +36 -0
- package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +360 -0
- package/src/runtime/migrations/migration-transport.ts +1 -0
- package/src/runtime/migrations/migration-wizard.ts +1 -0
- package/src/runtime/migrations/vbundle-import-analyzer.ts +77 -1
- package/src/runtime/migrations/vbundle-importer.ts +34 -0
- package/src/runtime/pending-interactions.ts +0 -11
- package/src/runtime/routes/__tests__/backup-routes.test.ts +967 -0
- package/src/runtime/routes/__tests__/home-feed-routes.test.ts +507 -0
- package/src/runtime/routes/__tests__/migration-import-credential-filter.test.ts +208 -0
- package/src/runtime/routes/__tests__/stt-routes.test.ts +406 -0
- package/src/runtime/routes/__tests__/tts-routes.test.ts +474 -0
- package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +148 -17
- package/src/runtime/routes/app-management-routes.ts +12 -18
- package/src/runtime/routes/attachment-routes.test.ts +9 -3
- package/src/runtime/routes/attachment-routes.ts +216 -17
- package/src/runtime/routes/backup-routes.ts +519 -0
- package/src/runtime/routes/browser-extension-pair-routes.ts +82 -23
- package/src/runtime/routes/btw-routes.ts +8 -6
- package/src/runtime/routes/contact-routes.test.ts +298 -0
- package/src/runtime/routes/contact-routes.ts +132 -5
- package/src/runtime/routes/conversation-analysis-routes.ts +22 -142
- package/src/runtime/routes/conversation-management-routes.ts +115 -0
- package/src/runtime/routes/conversation-routes.ts +367 -146
- package/src/runtime/routes/filing-routes.ts +93 -0
- package/src/runtime/routes/home-feed-routes.ts +334 -0
- package/src/runtime/routes/home-state-routes.ts +138 -0
- package/src/runtime/routes/host-browser-routes.ts +3 -14
- package/src/runtime/routes/identity-intro-cache.ts +7 -3
- package/src/runtime/routes/identity-routes.ts +3 -17
- package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +46 -39
- package/src/runtime/routes/inbound-stages/transcribe-audio.ts +15 -15
- package/src/runtime/routes/integrations/slack/__tests__/channel.test.ts +137 -0
- package/src/runtime/routes/integrations/slack/__tests__/share.test.ts +179 -0
- package/src/runtime/routes/integrations/slack/channel.ts +11 -3
- package/src/runtime/routes/integrations/slack/share.ts +45 -7
- package/src/runtime/routes/llm-context-normalization.ts +303 -0
- package/src/runtime/routes/memory-item-routes.test.ts +3 -2
- package/src/runtime/routes/migration-routes.ts +40 -5
- package/src/runtime/routes/settings-routes.ts +22 -5
- package/src/runtime/routes/skills-routes.ts +76 -7
- package/src/runtime/routes/stt-routes.ts +233 -0
- package/src/runtime/routes/surface-action-routes.ts +41 -2
- package/src/runtime/routes/tts-routes.ts +108 -24
- package/src/runtime/routes/usage-routes.ts +30 -2
- package/src/runtime/routes/user-route-dispatcher.ts +50 -5
- package/src/runtime/routes/user-routes.ts +13 -1
- package/src/runtime/routes/work-items-routes.ts +8 -1
- package/src/runtime/runtime-mode.ts +33 -0
- package/src/runtime/services/__tests__/analyze-conversation.test.ts +444 -0
- package/src/runtime/services/__tests__/analyze-deps-singleton.test.ts +67 -0
- package/src/runtime/services/__tests__/auto-analysis-prompt.test.ts +53 -0
- package/src/runtime/services/__tests__/manual-analysis-prompt.test.ts +41 -0
- package/src/runtime/services/analyze-conversation.ts +344 -0
- package/src/runtime/services/analyze-deps-singleton.ts +32 -0
- package/src/runtime/services/auto-analysis-prompt.ts +55 -0
- package/src/runtime/skill-route-registry.ts +49 -0
- package/src/runtime/slack-block-formatting.ts +437 -10
- package/src/schedule/scheduler.ts +50 -0
- package/src/security/oauth2.ts +26 -4
- package/src/security/secure-keys.ts +25 -2
- package/src/security/token-manager.ts +8 -0
- package/src/sequence/engine.ts +23 -0
- package/src/sequence/types.ts +1 -1
- package/src/skills/catalog-files.ts +64 -2
- package/src/skills/category-inference.ts +122 -0
- package/src/skills/clawhub-files.ts +213 -0
- package/src/skills/clawhub.ts +84 -23
- package/src/skills/skill-file-provider.ts +40 -0
- package/src/skills/skillssh-files.ts +395 -0
- package/src/skills/skillssh-registry.ts +4 -4
- package/src/stt/__tests__/daemon-batch-transcriber.test.ts +392 -0
- package/src/stt/__tests__/types.test.ts +89 -0
- package/src/stt/daemon-batch-transcriber.ts +195 -0
- package/src/stt/stt-stream-session.ts +499 -0
- package/src/stt/types.ts +330 -0
- package/src/stt/wav-encoder.test.ts +373 -0
- package/src/stt/wav-encoder.ts +175 -0
- package/src/subagent/manager.ts +38 -14
- package/src/tools/browser/__tests__/browser-mode.test.ts +119 -0
- package/src/tools/browser/__tests__/browser-status.test.ts +123 -0
- package/src/tools/browser/browser-execution.ts +1163 -23
- package/src/tools/browser/browser-manager.ts +45 -0
- package/src/tools/browser/browser-mode-constants.ts +12 -0
- package/src/tools/browser/browser-mode.ts +92 -0
- package/src/tools/browser/browser-status-constants.ts +33 -0
- package/src/tools/browser/cdp-client/__tests__/cdp-inspect-client.test.ts +393 -0
- package/src/tools/browser/cdp-client/__tests__/extension-cdp-client.test.ts +29 -0
- package/src/tools/browser/cdp-client/__tests__/factory.test.ts +1648 -32
- package/src/tools/browser/cdp-client/cdp-inspect/__tests__/discovery.test.ts +264 -0
- package/src/tools/browser/cdp-client/cdp-inspect/discovery.ts +183 -17
- package/src/tools/browser/cdp-client/cdp-inspect-client.ts +254 -21
- package/src/tools/browser/cdp-client/errors.ts +15 -0
- package/src/tools/browser/cdp-client/extension-cdp-client.ts +39 -16
- package/src/tools/browser/cdp-client/factory.ts +797 -87
- package/src/tools/browser/cdp-client/index.ts +16 -2
- package/src/tools/browser/cdp-client/types.ts +68 -0
- package/src/tools/credentials/vault.ts +35 -6
- package/src/tools/network/web-fetch.ts +5 -2
- package/src/tools/network/web-search.ts +5 -2
- package/src/tools/shared/shell-output.ts +3 -1
- package/src/tools/side-effects.ts +2 -0
- package/src/tools/skills/sandbox-runner.ts +3 -2
- package/src/tools/terminal/safe-env.ts +10 -2
- package/src/tools/terminal/shell.ts +15 -4
- package/src/tools/tool-manifest.ts +21 -0
- package/src/tools/types.ts +17 -0
- package/src/tools/ui-surface/definitions.ts +6 -1
- package/src/tts/__tests__/provider-adapters.test.ts +834 -0
- package/src/tts/__tests__/provider-catalog-consistency.test.ts +196 -0
- package/src/tts/__tests__/provider-catalog.test.ts +183 -0
- package/src/tts/__tests__/provider-registry.test.ts +90 -0
- package/src/tts/provider-catalog.ts +201 -0
- package/src/tts/provider-registry.ts +73 -0
- package/src/tts/providers/deepgram-provider.ts +219 -0
- package/src/tts/providers/elevenlabs-provider.ts +211 -0
- package/src/tts/providers/fish-audio-provider.ts +183 -0
- package/src/tts/providers/index.ts +42 -0
- package/src/tts/providers/register-builtins.ts +130 -0
- package/src/tts/synthesize-text.ts +110 -0
- package/src/tts/tts-config-resolver.ts +78 -0
- package/src/tts/types.ts +153 -0
- package/src/types/onboarding-context.ts +7 -0
- package/src/util/abort-reasons.ts +58 -0
- package/src/util/device-id.ts +32 -16
- package/src/util/errors.ts +9 -1
- package/src/util/platform.ts +54 -10
- package/src/util/pricing.ts +66 -3
- package/src/util/spawn.ts +1 -1
- package/src/util/truncate.ts +4 -2
- package/src/util/unicode.ts +201 -0
- package/src/version.ts +19 -24
- package/src/watcher/engine.ts +23 -0
- package/src/watcher/watcher-store.ts +31 -0
- package/src/workspace/migrations/003-seed-device-id.ts +9 -3
- package/src/workspace/migrations/017-seed-persona-dirs.ts +68 -4
- package/src/workspace/migrations/029-seed-pkb.ts +1 -1
- package/src/workspace/migrations/031-drop-user-md.ts +317 -0
- package/src/workspace/migrations/031-llm-log-retention-zero-to-null.ts +73 -0
- package/src/workspace/migrations/032-tts-provider-unification.ts +227 -0
- package/src/workspace/migrations/033-stt-service-explicit-config.ts +122 -0
- package/src/workspace/migrations/034-remove-calls-voice-transcription-provider.ts +215 -0
- package/src/workspace/migrations/035-seed-slack-channel-persona.ts +50 -0
- package/src/workspace/migrations/036-update-pkb-index-bar.ts +37 -0
- package/src/workspace/migrations/037-create-meets-dir.ts +61 -0
- package/src/workspace/migrations/registry.ts +16 -0
- package/src/workspace/top-level-renderer.ts +13 -1
- package/src/workspace/turn-commit.ts +31 -0
- package/src/__tests__/email-cli.test.ts +0 -297
- package/src/__tests__/email-service-config-fallback.test.ts +0 -102
- package/src/cli/commands/browser-relay.ts +0 -466
- package/src/email/guardrails.ts +0 -221
- package/src/email/provider.ts +0 -117
- package/src/email/providers/agentmail.ts +0 -361
- package/src/email/providers/index.ts +0 -65
- package/src/email/service.ts +0 -384
- package/src/email/types.ts +0 -126
- package/src/prompts/templates/USER.md +0 -13
- package/src/providers/speech-to-text/types.ts +0 -17
- package/src/runtime/routes/browser-cdp-routes.ts +0 -229
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Home activity feed writer.
|
|
3
|
+
*
|
|
4
|
+
* Owns `<workspace>/data/home-feed.json`, the daemon-side source of
|
|
5
|
+
* truth for the macOS Home page activity feed. Handles the merge
|
|
6
|
+
* semantics defined by the TDD / plan:
|
|
7
|
+
*
|
|
8
|
+
* - Digest replacement: at most one digest per `source`. A fresh
|
|
9
|
+
* digest for a source replaces any prior digest for the same
|
|
10
|
+
* source in place.
|
|
11
|
+
* - Thread in-place update: if an incoming `thread` item shares its
|
|
12
|
+
* `id` with an existing item, replace that item while preserving
|
|
13
|
+
* its array position so the UI does not jitter on updates.
|
|
14
|
+
* - Author resolution: for matching `(type, source)` pairs the
|
|
15
|
+
* hybrid-authoring precedence is `assistant` beats `platform` —
|
|
16
|
+
* an assistant item overwrites an existing platform item for the
|
|
17
|
+
* same pair, but a platform item never overwrites an existing
|
|
18
|
+
* assistant item (no-op). Applies to nudges; actions are exempt
|
|
19
|
+
* (see next bullet).
|
|
20
|
+
* - Action append-without-replace: `action` items are the feed's
|
|
21
|
+
* activity log and never merge by `(type, source)` — each append
|
|
22
|
+
* becomes a distinct entry so successive background-job events
|
|
23
|
+
* don't collapse onto each other. A same-`id` action is the one
|
|
24
|
+
* exception: it performs an in-place update (same semantics as
|
|
25
|
+
* threads) so callers using a deterministic dedup id via
|
|
26
|
+
* `emit-feed-event.ts` can refresh an entry without appending a
|
|
27
|
+
* duplicate. Callers that want to auto-expire an action item
|
|
28
|
+
* must set `expiresAt` explicitly; the writer does NOT fill in
|
|
29
|
+
* a default expiry.
|
|
30
|
+
* - Per-source action cap: after merge, each source keeps at most
|
|
31
|
+
* {@link MAX_ACTIONS_PER_SOURCE} action items (most recent by
|
|
32
|
+
* `createdAt`). Older actions for that source are dropped so the
|
|
33
|
+
* on-disk file can't balloon as background jobs emit events.
|
|
34
|
+
* Action items without a `source` are unbounded and passed
|
|
35
|
+
* through untouched.
|
|
36
|
+
* - TTL filter on read: `readHomeFeed` drops any item whose
|
|
37
|
+
* `expiresAt` is in the past. This is a stateless sweep — the
|
|
38
|
+
* writer does not rewrite the file on read, so concurrent reads
|
|
39
|
+
* never race the writer.
|
|
40
|
+
*
|
|
41
|
+
* Concurrent writers are coalesced with the exact same "latest wins"
|
|
42
|
+
* pattern as `relationship-state-writer.ts`: at most one compute+write
|
|
43
|
+
* runs at a time, and overlapping calls during an in-flight write all
|
|
44
|
+
* resolve off a single tail write that reflects the final state.
|
|
45
|
+
*
|
|
46
|
+
* Each successful write publishes a `home_feed_updated` SSE event via
|
|
47
|
+
* the in-process `assistantEventHub`, carrying the post-filter count
|
|
48
|
+
* of items with `status === "new"` so subscribers can update unread
|
|
49
|
+
* badges without a full refetch.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
53
|
+
import { join } from "node:path";
|
|
54
|
+
|
|
55
|
+
import { buildAssistantEvent } from "../runtime/assistant-event.js";
|
|
56
|
+
import { assistantEventHub } from "../runtime/assistant-event-hub.js";
|
|
57
|
+
import { DAEMON_INTERNAL_ASSISTANT_ID } from "../runtime/assistant-scope.js";
|
|
58
|
+
import { getLogger } from "../util/logger.js";
|
|
59
|
+
import { getDataDir } from "../util/platform.js";
|
|
60
|
+
import {
|
|
61
|
+
type FeedItem,
|
|
62
|
+
type FeedItemStatus,
|
|
63
|
+
type HomeFeedFile,
|
|
64
|
+
parseFeedFile,
|
|
65
|
+
} from "./feed-types.js";
|
|
66
|
+
|
|
67
|
+
const log = getLogger("home-feed-writer");
|
|
68
|
+
|
|
69
|
+
/** Filename for the on-disk home feed. Lives under the workspace data dir. */
|
|
70
|
+
export const HOME_FEED_FILENAME = "home-feed.json";
|
|
71
|
+
|
|
72
|
+
/** On-disk file-format version. Bump + migrate if the shape changes. */
|
|
73
|
+
export const HOME_FEED_VERSION = 1;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Per-source volume cap for `action` items. When the post-merge item
|
|
77
|
+
* list has more than this many action items for a single source, the
|
|
78
|
+
* oldest (by `createdAt`) are dropped until the count is back within
|
|
79
|
+
* the cap. Other item types are unaffected, and action items without
|
|
80
|
+
* a `source` are also unaffected.
|
|
81
|
+
*/
|
|
82
|
+
export const MAX_ACTIONS_PER_SOURCE = 20;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Canonical path to the home-feed snapshot
|
|
86
|
+
* (`<workspace>/data/home-feed.json`).
|
|
87
|
+
*/
|
|
88
|
+
export function getHomeFeedPath(): string {
|
|
89
|
+
return join(getDataDir(), HOME_FEED_FILENAME);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Read the on-disk feed file, applying the stateless TTL filter.
|
|
94
|
+
*
|
|
95
|
+
* Returns an empty `HomeFeedFile` when the file is missing, unreadable,
|
|
96
|
+
* or fails Zod validation — callers never see a throw from this path.
|
|
97
|
+
* Items whose `expiresAt` is in the past are dropped from the returned
|
|
98
|
+
* `items` array but are NOT rewritten to disk; the next append cycle
|
|
99
|
+
* will persist the post-filter view naturally.
|
|
100
|
+
*/
|
|
101
|
+
export function readHomeFeed(): HomeFeedFile {
|
|
102
|
+
const path = getHomeFeedPath();
|
|
103
|
+
const empty: HomeFeedFile = {
|
|
104
|
+
version: HOME_FEED_VERSION,
|
|
105
|
+
items: [],
|
|
106
|
+
updatedAt: new Date(0).toISOString(),
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
if (!existsSync(path)) {
|
|
110
|
+
return empty;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let raw: unknown;
|
|
114
|
+
try {
|
|
115
|
+
raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
116
|
+
} catch (err) {
|
|
117
|
+
log.warn({ err, path }, "Failed to read home-feed.json; returning empty");
|
|
118
|
+
return empty;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let parsed: HomeFeedFile;
|
|
122
|
+
try {
|
|
123
|
+
parsed = parseFeedFile(raw);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
log.warn(
|
|
126
|
+
{ err, path },
|
|
127
|
+
"home-feed.json failed schema validation; returning empty",
|
|
128
|
+
);
|
|
129
|
+
return empty;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const now = Date.now();
|
|
133
|
+
const items = parsed.items.filter((item) => !isExpired(item, now));
|
|
134
|
+
return {
|
|
135
|
+
version: parsed.version,
|
|
136
|
+
items,
|
|
137
|
+
updatedAt: parsed.updatedAt,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Append (or merge) a single feed item and persist the result.
|
|
143
|
+
*
|
|
144
|
+
* See the module comment for the precise merge semantics. Never
|
|
145
|
+
* throws — all failures degrade to a warn-log so fire-and-forget
|
|
146
|
+
* callers in the daemon don't need a try/catch wrapper. Concurrent
|
|
147
|
+
* calls are coalesced via the in-module `writeInFlight` / `writeDirty`
|
|
148
|
+
* pattern so at most one write is in flight at a time.
|
|
149
|
+
*/
|
|
150
|
+
export async function appendFeedItem(item: FeedItem): Promise<void> {
|
|
151
|
+
pendingAppends.push(item);
|
|
152
|
+
return scheduleWrite();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Update the `status` field of a single feed item by id.
|
|
157
|
+
*
|
|
158
|
+
* Returns the updated `FeedItem` on success, or `null` if no item with
|
|
159
|
+
* the given id exists. This is the path the HTTP route uses when the
|
|
160
|
+
* client marks an item as `"seen"` or `"acted_on"`. Concurrent patches
|
|
161
|
+
* go through the same coalescing queue as `appendFeedItem` so two
|
|
162
|
+
* overlapping status flips can't race each other.
|
|
163
|
+
*
|
|
164
|
+
* The patch is applied inside `runWrite()` so the existence check
|
|
165
|
+
* reads from the same state snapshot the mutation will land on —
|
|
166
|
+
* callers never observe a "phantom success" where we return an
|
|
167
|
+
* updated item for an id that no longer exists on disk by the time
|
|
168
|
+
* the queued write runs.
|
|
169
|
+
*/
|
|
170
|
+
export async function patchFeedItemStatus(
|
|
171
|
+
id: string,
|
|
172
|
+
status: FeedItemStatus,
|
|
173
|
+
): Promise<FeedItem | null> {
|
|
174
|
+
let resolveResult!: (value: FeedItem | null) => void;
|
|
175
|
+
const resultPromise = new Promise<FeedItem | null>((resolve) => {
|
|
176
|
+
resolveResult = resolve;
|
|
177
|
+
});
|
|
178
|
+
pendingPatches.push({ id, status, resolve: resolveResult });
|
|
179
|
+
void scheduleWrite();
|
|
180
|
+
return resultPromise;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ─── Internal: coalescing queue ────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Pending operations that land in the next coalesced write cycle.
|
|
187
|
+
* Appends and patches drain together so overlapping callers share a
|
|
188
|
+
* single compute+write tail.
|
|
189
|
+
*/
|
|
190
|
+
const pendingAppends: FeedItem[] = [];
|
|
191
|
+
const pendingPatches: Array<{
|
|
192
|
+
id: string;
|
|
193
|
+
status: FeedItemStatus;
|
|
194
|
+
resolve: (value: FeedItem | null) => void;
|
|
195
|
+
}> = [];
|
|
196
|
+
|
|
197
|
+
let writeInFlight: Promise<void> | null = null;
|
|
198
|
+
let writeDirty = false;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Enqueue a write cycle. Mirrors the `relationship-state-writer.ts`
|
|
202
|
+
* coalescing pattern exactly: the first caller kicks off a run; any
|
|
203
|
+
* callers that arrive during an in-flight run mark dirty and resolve
|
|
204
|
+
* off the same tail promise, so N overlapping callers produce at most
|
|
205
|
+
* two runs (the initial + one coalesced tail).
|
|
206
|
+
*/
|
|
207
|
+
function scheduleWrite(): Promise<void> {
|
|
208
|
+
if (writeInFlight) {
|
|
209
|
+
writeDirty = true;
|
|
210
|
+
return writeInFlight;
|
|
211
|
+
}
|
|
212
|
+
writeInFlight = (async () => {
|
|
213
|
+
try {
|
|
214
|
+
await runWrite();
|
|
215
|
+
while (writeDirty) {
|
|
216
|
+
writeDirty = false;
|
|
217
|
+
await runWrite();
|
|
218
|
+
}
|
|
219
|
+
} finally {
|
|
220
|
+
writeInFlight = null;
|
|
221
|
+
}
|
|
222
|
+
})();
|
|
223
|
+
return writeInFlight;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Drain the pending-operations queue into a fresh on-disk snapshot
|
|
228
|
+
* and publish the SSE event. Never throws — the write error is caught
|
|
229
|
+
* + logged so the coalescing loop can still move on to the next cycle.
|
|
230
|
+
*/
|
|
231
|
+
async function runWrite(): Promise<void> {
|
|
232
|
+
const appendsToApply = pendingAppends.splice(0, pendingAppends.length);
|
|
233
|
+
const patchesToApply = pendingPatches.splice(0, pendingPatches.length);
|
|
234
|
+
|
|
235
|
+
const current = readHomeFeed();
|
|
236
|
+
let items = current.items.slice();
|
|
237
|
+
|
|
238
|
+
for (const incoming of appendsToApply) {
|
|
239
|
+
items = mergeIncoming(items, incoming);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
items = pruneActionsPerSource(items);
|
|
243
|
+
|
|
244
|
+
// Track the per-patch result so callers can distinguish an update
|
|
245
|
+
// from an unknown-id no-op. We collect resolvers first and fire them
|
|
246
|
+
// after the write lands so the resolved `FeedItem` matches on-disk
|
|
247
|
+
// state exactly.
|
|
248
|
+
const patchResults: Array<{
|
|
249
|
+
resolve: (v: FeedItem | null) => void;
|
|
250
|
+
value: FeedItem | null;
|
|
251
|
+
}> = [];
|
|
252
|
+
for (const patch of patchesToApply) {
|
|
253
|
+
const idx = items.findIndex((i) => i.id === patch.id);
|
|
254
|
+
if (idx === -1) {
|
|
255
|
+
patchResults.push({ resolve: patch.resolve, value: null });
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const updated: FeedItem = { ...items[idx]!, status: patch.status };
|
|
259
|
+
items[idx] = updated;
|
|
260
|
+
patchResults.push({ resolve: patch.resolve, value: updated });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
items.sort(compareFeedItems);
|
|
264
|
+
|
|
265
|
+
const updatedAt = new Date().toISOString();
|
|
266
|
+
const next: HomeFeedFile = {
|
|
267
|
+
version: HOME_FEED_VERSION,
|
|
268
|
+
items,
|
|
269
|
+
updatedAt,
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
let wrote = false;
|
|
273
|
+
try {
|
|
274
|
+
const path = getHomeFeedPath();
|
|
275
|
+
mkdirSync(getDataDir(), { recursive: true });
|
|
276
|
+
writeFileSync(path, JSON.stringify(next, null, 2), "utf-8");
|
|
277
|
+
wrote = true;
|
|
278
|
+
log.info({ path, items: items.length }, "Wrote home-feed.json");
|
|
279
|
+
} catch (err) {
|
|
280
|
+
log.warn({ err }, "Failed to write home-feed.json");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (wrote) {
|
|
284
|
+
const newItemCount = items.filter((i) => i.status === "new").length;
|
|
285
|
+
publishHomeFeedUpdated(updatedAt, newItemCount);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Resolve pending patch promises AFTER we've emitted the SSE event
|
|
289
|
+
// so callers awaiting `patchFeedItemStatus` observe a fully
|
|
290
|
+
// consistent world: the on-disk file, the SSE event, and the
|
|
291
|
+
// returned `FeedItem` all reflect the same write.
|
|
292
|
+
//
|
|
293
|
+
// If the write failed, resolve all patch promises with `null` — the
|
|
294
|
+
// state was not persisted, and callers (e.g. HTTP route handlers)
|
|
295
|
+
// must not report success when the underlying write failed.
|
|
296
|
+
for (const { resolve, value } of patchResults) {
|
|
297
|
+
resolve(wrote ? value : null);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Apply the merge semantics for a single incoming item against the
|
|
303
|
+
* current item list and return a new list. Pure function — the input
|
|
304
|
+
* array is not mutated.
|
|
305
|
+
*/
|
|
306
|
+
function mergeIncoming(items: FeedItem[], incoming: FeedItem): FeedItem[] {
|
|
307
|
+
// Digest replacement: one digest per source wins.
|
|
308
|
+
if (incoming.type === "digest" && incoming.source) {
|
|
309
|
+
const filtered = items.filter(
|
|
310
|
+
(i) => !(i.type === "digest" && i.source === incoming.source),
|
|
311
|
+
);
|
|
312
|
+
filtered.push(incoming);
|
|
313
|
+
return filtered;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Thread in-place update: same id wins, preserve position.
|
|
317
|
+
if (incoming.type === "thread") {
|
|
318
|
+
const idx = items.findIndex(
|
|
319
|
+
(i) => i.type === "thread" && i.id === incoming.id,
|
|
320
|
+
);
|
|
321
|
+
if (idx !== -1) {
|
|
322
|
+
const copy = items.slice();
|
|
323
|
+
copy[idx] = incoming;
|
|
324
|
+
return copy;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Action append-without-replace: each action item is a distinct
|
|
329
|
+
// activity-log entry and must NOT collapse onto an existing action
|
|
330
|
+
// for the same (type, source) pair. The per-source volume cap in
|
|
331
|
+
// `pruneActionsPerSource` keeps the log from growing unbounded.
|
|
332
|
+
//
|
|
333
|
+
// Exception: same-id in-place update. Callers that want
|
|
334
|
+
// deterministic dedup (e.g. via `emit-feed-event.ts`'s `dedupKey`)
|
|
335
|
+
// produce a stable id per logical event; a second emit with the
|
|
336
|
+
// same id refreshes the existing entry in place rather than
|
|
337
|
+
// appending a duplicate.
|
|
338
|
+
if (incoming.type === "action") {
|
|
339
|
+
const idx = items.findIndex(
|
|
340
|
+
(i) => i.type === "action" && i.id === incoming.id,
|
|
341
|
+
);
|
|
342
|
+
if (idx !== -1) {
|
|
343
|
+
const copy = items.slice();
|
|
344
|
+
copy[idx] = incoming;
|
|
345
|
+
return copy;
|
|
346
|
+
}
|
|
347
|
+
return [...items, incoming];
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Author resolution: for matching (type, source) pairs, assistant
|
|
351
|
+
// beats platform. A platform-authored incoming item against an
|
|
352
|
+
// existing assistant item is a no-op. Applies to nudges (actions
|
|
353
|
+
// short-circuit above).
|
|
354
|
+
if (incoming.source) {
|
|
355
|
+
const existingIdx = items.findIndex(
|
|
356
|
+
(i) => i.type === incoming.type && i.source === incoming.source,
|
|
357
|
+
);
|
|
358
|
+
if (existingIdx !== -1) {
|
|
359
|
+
const existing = items[existingIdx]!;
|
|
360
|
+
if (existing.author === "assistant" && incoming.author === "platform") {
|
|
361
|
+
// Platform can't overwrite assistant — no-op.
|
|
362
|
+
return items;
|
|
363
|
+
}
|
|
364
|
+
if (existing.author === "platform" && incoming.author === "assistant") {
|
|
365
|
+
const copy = items.slice();
|
|
366
|
+
copy[existingIdx] = incoming;
|
|
367
|
+
return copy;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return [...items, incoming];
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Enforce the per-source volume cap on `action` items. For each
|
|
377
|
+
* source that has more than {@link MAX_ACTIONS_PER_SOURCE} actions in
|
|
378
|
+
* the post-merge list, keep the most recent by `createdAt` and drop
|
|
379
|
+
* the rest. Other item types and action items without a `source` are
|
|
380
|
+
* passed through untouched. Stable with respect to non-affected items.
|
|
381
|
+
*/
|
|
382
|
+
function pruneActionsPerSource(items: FeedItem[]): FeedItem[] {
|
|
383
|
+
const actionsBySource = new Map<string, FeedItem[]>();
|
|
384
|
+
for (const item of items) {
|
|
385
|
+
if (item.type !== "action" || !item.source) continue;
|
|
386
|
+
const bucket = actionsBySource.get(item.source);
|
|
387
|
+
if (bucket) {
|
|
388
|
+
bucket.push(item);
|
|
389
|
+
} else {
|
|
390
|
+
actionsBySource.set(item.source, [item]);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const overflowing: string[] = [];
|
|
395
|
+
for (const [source, bucket] of actionsBySource) {
|
|
396
|
+
if (bucket.length > MAX_ACTIONS_PER_SOURCE) overflowing.push(source);
|
|
397
|
+
}
|
|
398
|
+
if (overflowing.length === 0) return items;
|
|
399
|
+
|
|
400
|
+
const keepIds = new Set<string>();
|
|
401
|
+
for (const source of overflowing) {
|
|
402
|
+
const bucket = actionsBySource.get(source)!.slice();
|
|
403
|
+
bucket.sort((a, b) => {
|
|
404
|
+
const am = Date.parse(a.createdAt);
|
|
405
|
+
const bm = Date.parse(b.createdAt);
|
|
406
|
+
if (Number.isNaN(am) && Number.isNaN(bm)) return 0;
|
|
407
|
+
if (Number.isNaN(am)) return 1;
|
|
408
|
+
if (Number.isNaN(bm)) return -1;
|
|
409
|
+
return bm - am;
|
|
410
|
+
});
|
|
411
|
+
for (const item of bucket.slice(0, MAX_ACTIONS_PER_SOURCE)) {
|
|
412
|
+
keepIds.add(item.id);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return items.filter((item) => {
|
|
417
|
+
if (item.type !== "action") return true;
|
|
418
|
+
if (!item.source) return true;
|
|
419
|
+
if (!overflowing.includes(item.source)) return true;
|
|
420
|
+
return keepIds.has(item.id);
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Return `true` when the item has an `expiresAt` timestamp that is in
|
|
426
|
+
* the past relative to the supplied `nowMs`. Items without
|
|
427
|
+
* `expiresAt`, or with an unparseable value, are treated as not
|
|
428
|
+
* expired (fail-open).
|
|
429
|
+
*/
|
|
430
|
+
function isExpired(item: FeedItem, nowMs: number): boolean {
|
|
431
|
+
if (!item.expiresAt) return false;
|
|
432
|
+
const expiresMs = Date.parse(item.expiresAt);
|
|
433
|
+
if (Number.isNaN(expiresMs)) return false;
|
|
434
|
+
return expiresMs <= nowMs;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Sort comparator: priority DESC, then createdAt DESC. Matches the
|
|
439
|
+
* ordering contract the UI expects so higher-priority and fresher
|
|
440
|
+
* items sort to the top of the feed.
|
|
441
|
+
*/
|
|
442
|
+
function compareFeedItems(a: FeedItem, b: FeedItem): number {
|
|
443
|
+
if (a.priority !== b.priority) return b.priority - a.priority;
|
|
444
|
+
const aMs = Date.parse(a.createdAt);
|
|
445
|
+
const bMs = Date.parse(b.createdAt);
|
|
446
|
+
if (Number.isNaN(aMs) && Number.isNaN(bMs)) return 0;
|
|
447
|
+
if (Number.isNaN(aMs)) return 1;
|
|
448
|
+
if (Number.isNaN(bMs)) return -1;
|
|
449
|
+
return bMs - aMs;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Publish a `home_feed_updated` event to the in-process hub. Wrapped
|
|
454
|
+
* in a `.catch` so a subscriber rejection never bubbles up into the
|
|
455
|
+
* writer coalescing loop.
|
|
456
|
+
*/
|
|
457
|
+
function publishHomeFeedUpdated(updatedAt: string, newItemCount: number): void {
|
|
458
|
+
assistantEventHub
|
|
459
|
+
.publish(
|
|
460
|
+
buildAssistantEvent(DAEMON_INTERNAL_ASSISTANT_ID, {
|
|
461
|
+
type: "home_feed_updated",
|
|
462
|
+
updatedAt,
|
|
463
|
+
newItemCount,
|
|
464
|
+
}),
|
|
465
|
+
)
|
|
466
|
+
.catch((err) => {
|
|
467
|
+
log.warn({ err }, "Failed to publish home_feed_updated event");
|
|
468
|
+
});
|
|
469
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform-baseline Gmail digest generator.
|
|
3
|
+
*
|
|
4
|
+
* Produces a mechanical "N new emails" digest FeedItem for the home
|
|
5
|
+
* activity feed. This is the first platform-authored feed source —
|
|
6
|
+
* it writes a digest item via the feed writer. Scheduling/invocation
|
|
7
|
+
* wiring lands in a follow-up PR when the end-to-end feed flow is
|
|
8
|
+
* turned on.
|
|
9
|
+
*
|
|
10
|
+
* Design notes:
|
|
11
|
+
*
|
|
12
|
+
* - No LLM calls. Title and summary are purely mechanical so this
|
|
13
|
+
* path stays cheap and deterministic. Assistant-authored nudges
|
|
14
|
+
* can override a platform digest for the same `(type, source)`
|
|
15
|
+
* pair via the feed writer's hybrid-authoring resolver (see
|
|
16
|
+
* `feed-writer.ts`).
|
|
17
|
+
* - No direct Gmail API fetches. The count is read from whatever
|
|
18
|
+
* integration cache already exists. A dependency-injected count
|
|
19
|
+
* source keeps the function testable and leaves room for the real
|
|
20
|
+
* integration wiring to land in a follow-up PR.
|
|
21
|
+
* - One-per-source replacement is handled by the writer — a fresh
|
|
22
|
+
* digest automatically replaces any prior Gmail digest in place
|
|
23
|
+
* on each call.
|
|
24
|
+
* - `minTimeAway: 3600` (1 hour) avoids showing the digest to users
|
|
25
|
+
* who've only briefly stepped away. Priority `40` is mid-tier so
|
|
26
|
+
* assistant-authored items naturally win on the sort.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { randomUUID } from "node:crypto";
|
|
30
|
+
|
|
31
|
+
import { getLogger } from "../util/logger.js";
|
|
32
|
+
import type { FeedItem } from "./feed-types.js";
|
|
33
|
+
import { appendFeedItem, readHomeFeed } from "./feed-writer.js";
|
|
34
|
+
|
|
35
|
+
const log = getLogger("platform-gmail-digest");
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Count source for pending Gmail emails. Kept as an injectable
|
|
39
|
+
* dependency so tests can supply a deterministic number and so the
|
|
40
|
+
* real wiring (an integration cache / event bus) can be swapped in
|
|
41
|
+
* without touching this module.
|
|
42
|
+
*
|
|
43
|
+
* The default source returns 0 — there is no persistent, platform-
|
|
44
|
+
* wide Gmail inbox count tracked in the daemon today, so the default
|
|
45
|
+
* path is a no-op until a real count source is wired in.
|
|
46
|
+
*/
|
|
47
|
+
export type GmailCountSource = () => Promise<number>;
|
|
48
|
+
|
|
49
|
+
async function defaultGmailCountSource(): Promise<number> {
|
|
50
|
+
// No platform-wide Gmail inbox count exists in the daemon yet.
|
|
51
|
+
// Callers pass an explicit `countSource` from whatever integration
|
|
52
|
+
// state is appropriate for their context (tests, watcher store,
|
|
53
|
+
// future integration event bus, etc.).
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build and append a platform-baseline Gmail digest feed item.
|
|
59
|
+
*
|
|
60
|
+
* Returns `null` when the count is 0 (no-op; we do not write an
|
|
61
|
+
* empty digest). Otherwise returns the constructed `FeedItem` after
|
|
62
|
+
* successfully enqueueing it via `appendFeedItem`.
|
|
63
|
+
*
|
|
64
|
+
* Never throws — all failures degrade to a warn-log so the caller
|
|
65
|
+
* (a scheduler tick) can fire-and-forget without a try/catch.
|
|
66
|
+
*/
|
|
67
|
+
export async function generateGmailDigest(
|
|
68
|
+
now: Date,
|
|
69
|
+
countSource: GmailCountSource = defaultGmailCountSource,
|
|
70
|
+
): Promise<FeedItem | null> {
|
|
71
|
+
let count: number;
|
|
72
|
+
try {
|
|
73
|
+
count = await countSource();
|
|
74
|
+
} catch (err) {
|
|
75
|
+
log.warn({ err }, "Gmail count source threw; skipping digest");
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!Number.isFinite(count) || count <= 0) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const flooredCount = Math.floor(count);
|
|
84
|
+
const timestamp = now.toISOString();
|
|
85
|
+
const summary = buildDigestSummary(now);
|
|
86
|
+
|
|
87
|
+
const item: FeedItem = {
|
|
88
|
+
id: randomUUID(),
|
|
89
|
+
type: "digest",
|
|
90
|
+
source: "gmail",
|
|
91
|
+
author: "platform",
|
|
92
|
+
title: `${flooredCount} new email${flooredCount === 1 ? "" : "s"}`,
|
|
93
|
+
summary,
|
|
94
|
+
priority: 40,
|
|
95
|
+
minTimeAway: 3600,
|
|
96
|
+
timestamp,
|
|
97
|
+
createdAt: timestamp,
|
|
98
|
+
status: "new",
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
await appendFeedItem(item);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
log.warn({ err }, "Failed to append Gmail digest to feed");
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return item;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Builds the digest summary line. Reads the prior Gmail digest's
|
|
113
|
+
* timestamp from the feed and formats it as `"Since <short time>"`
|
|
114
|
+
* so users can anchor "new emails" to a specific moment. Falls back
|
|
115
|
+
* to a generic string on first-ever digest or on any read failure
|
|
116
|
+
* (the writer is authoritative; we never throw out of the generator).
|
|
117
|
+
*/
|
|
118
|
+
function buildDigestSummary(now: Date): string {
|
|
119
|
+
const priorTimestamp = readPriorGmailDigestTimestamp();
|
|
120
|
+
if (priorTimestamp == null) {
|
|
121
|
+
return "Since your last check-in";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const priorDate = new Date(priorTimestamp);
|
|
125
|
+
if (Number.isNaN(priorDate.getTime())) {
|
|
126
|
+
return "Since your last check-in";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return `Since ${formatShortTime(priorDate, now)}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function readPriorGmailDigestTimestamp(): string | null {
|
|
133
|
+
try {
|
|
134
|
+
const feed = readHomeFeed();
|
|
135
|
+
const prior = feed.items.find(
|
|
136
|
+
(item) => item.type === "digest" && item.source === "gmail",
|
|
137
|
+
);
|
|
138
|
+
return prior?.timestamp ?? null;
|
|
139
|
+
} catch (err) {
|
|
140
|
+
log.warn({ err }, "Failed to read prior Gmail digest timestamp");
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Same-day prior → "10:32 AM". Cross-day prior → "Mon 10:32 AM".
|
|
147
|
+
* Plain `toLocaleTimeString` would conflate yesterday and today.
|
|
148
|
+
*/
|
|
149
|
+
function formatShortTime(prior: Date, now: Date): string {
|
|
150
|
+
const time = prior.toLocaleTimeString("en-US", {
|
|
151
|
+
hour: "numeric",
|
|
152
|
+
minute: "2-digit",
|
|
153
|
+
});
|
|
154
|
+
const sameDay =
|
|
155
|
+
prior.getFullYear() === now.getFullYear() &&
|
|
156
|
+
prior.getMonth() === now.getMonth() &&
|
|
157
|
+
prior.getDate() === now.getDate();
|
|
158
|
+
if (sameDay) {
|
|
159
|
+
return time;
|
|
160
|
+
}
|
|
161
|
+
const weekday = prior.toLocaleDateString("en-US", { weekday: "short" });
|
|
162
|
+
return `${weekday} ${time}`;
|
|
163
|
+
}
|