@vellumai/assistant 0.5.16 → 0.6.0
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 +1 -1
- package/Dockerfile +0 -3
- package/knip.json +2 -1
- package/openapi.yaml +660 -80
- package/package.json +1 -1
- package/src/__tests__/actor-token-service.test.ts +68 -0
- package/src/__tests__/agent-loop.test.ts +0 -32
- package/src/__tests__/always-loaded-tools-guard.test.ts +2 -2
- package/src/__tests__/anthropic-provider.test.ts +57 -3
- package/src/__tests__/app-compiler.test.ts +120 -0
- package/src/__tests__/assistant-feature-flags-integration.test.ts +2 -2
- package/src/__tests__/call-conversation-messages.test.ts +2 -6
- package/src/__tests__/call-domain.test.ts +2 -6
- package/src/__tests__/call-pointer-messages.test.ts +2 -14
- package/src/__tests__/call-recovery.test.ts +2 -6
- package/src/__tests__/call-routes-http.test.ts +2 -6
- package/src/__tests__/call-store.test.ts +2 -6
- package/src/__tests__/cancel-resolves-conversation-key.test.ts +2 -6
- package/src/__tests__/canonical-guardian-store.test.ts +2 -6
- package/src/__tests__/channel-delivery-store.test.ts +2 -6
- package/src/__tests__/channel-retry-sweep.test.ts +2 -6
- package/src/__tests__/checker.test.ts +25 -3
- package/src/__tests__/clawhub.test.ts +54 -24
- package/src/__tests__/cli-command-risk-guard.test.ts +14 -0
- package/src/__tests__/cli-memory.test.ts +74 -69
- package/src/__tests__/config-schema.test.ts +1 -1
- package/src/__tests__/config-set-platform-guard.test.ts +302 -0
- package/src/__tests__/confirmation-request-guardian-bridge.test.ts +2 -6
- package/src/__tests__/contacts-tools.test.ts +31 -0
- package/src/__tests__/context-overflow-reducer.test.ts +86 -0
- package/src/__tests__/context-token-estimator.test.ts +175 -10
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +9 -0
- package/src/__tests__/conversation-agent-loop.test.ts +9 -0
- package/src/__tests__/conversation-attachments.test.ts +2 -6
- package/src/__tests__/conversation-attention-store.test.ts +2 -6
- package/src/__tests__/conversation-clear-safety.test.ts +2 -6
- package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +4 -10
- package/src/__tests__/conversation-disk-view-integration.test.ts +2 -6
- package/src/__tests__/conversation-disk-view.test.ts +2 -6
- package/src/__tests__/conversation-error.test.ts +33 -2
- package/src/__tests__/conversation-fork-crud.test.ts +2 -6
- package/src/__tests__/conversation-history-web-search.test.ts +5 -0
- package/src/__tests__/conversation-load-history-repair.test.ts +5 -1
- package/src/__tests__/conversation-media-retry.test.ts +91 -0
- package/src/__tests__/conversation-starter-routes.test.ts +20 -11
- package/src/__tests__/conversation-store.test.ts +2 -6
- package/src/__tests__/conversation-usage.test.ts +2 -6
- package/src/__tests__/conversation-wipe.test.ts +11 -408
- package/src/__tests__/credential-execution-feature-gates.test.ts +3 -3
- package/src/__tests__/credential-execution-shell-lockdown.test.ts +2 -2
- package/src/__tests__/credential-security-e2e.test.ts +2 -0
- package/src/__tests__/followup-tools.test.ts +2 -6
- package/src/__tests__/graph-extraction-event-date.test.ts +186 -0
- package/src/__tests__/guardian-action-conversation-turn.test.ts +2 -6
- package/src/__tests__/guardian-action-followup-executor.test.ts +2 -6
- package/src/__tests__/guardian-action-followup-store.test.ts +2 -6
- package/src/__tests__/guardian-action-grant-mint-consume.test.ts +2 -6
- package/src/__tests__/guardian-action-late-reply.test.ts +2 -6
- package/src/__tests__/guardian-action-store.test.ts +2 -6
- package/src/__tests__/guardian-binding-drift-heal.test.ts +2 -6
- package/src/__tests__/guardian-decision-primitive-canonical.test.ts +8 -8
- package/src/__tests__/guardian-dispatch.test.ts +2 -6
- package/src/__tests__/guardian-grant-minting.test.ts +2 -14
- package/src/__tests__/guardian-principal-id-roundtrip.test.ts +2 -6
- package/src/__tests__/guardian-routing-invariants.test.ts +192 -6
- package/src/__tests__/guardian-routing-state.test.ts +2 -6
- package/src/__tests__/guardian-verification-voice-binding.test.ts +2 -6
- package/src/__tests__/inbound-invite-redemption.test.ts +2 -6
- package/src/__tests__/injection-block.test.ts +154 -0
- package/src/__tests__/install-meta.test.ts +506 -0
- package/src/__tests__/install-skill-routing.test.ts +292 -0
- package/src/__tests__/invite-redemption-service.test.ts +2 -6
- package/src/__tests__/invite-routes-http.test.ts +2 -6
- package/src/__tests__/jobs-store-qdrant-breaker.test.ts +2 -14
- package/src/__tests__/list-messages-attachments.test.ts +2 -6
- package/src/__tests__/llm-context-route-provider.test.ts +2 -6
- package/src/__tests__/llm-request-log-turn-query.test.ts +2 -6
- package/src/__tests__/llm-usage-store.test.ts +2 -6
- package/src/__tests__/log-export-workspace.test.ts +2 -6
- package/src/__tests__/managed-store.test.ts +38 -11
- package/src/__tests__/memory-jobs-worker-backoff.test.ts +2 -8
- package/src/__tests__/memory-recall-log-store.test.ts +2 -6
- package/src/__tests__/memory-upsert-concurrency.test.ts +4 -112
- package/src/__tests__/non-member-access-request.test.ts +2 -6
- package/src/__tests__/notification-guardian-path.test.ts +2 -6
- package/src/__tests__/oauth-cli.test.ts +364 -2
- package/src/__tests__/oauth2-gateway-transport.test.ts +18 -3
- package/src/__tests__/outlook-attachments.test.ts +301 -0
- package/src/__tests__/outlook-automation-tools.test.ts +425 -0
- package/src/__tests__/outlook-categories.test.ts +212 -0
- package/src/__tests__/outlook-client-automation.test.ts +246 -0
- package/src/__tests__/outlook-compose-tools.test.ts +325 -0
- package/src/__tests__/outlook-declutter-tools.test.ts +585 -0
- package/src/__tests__/outlook-email-watcher.test.ts +322 -0
- package/src/__tests__/outlook-follow-up.test.ts +196 -0
- package/src/__tests__/outlook-messaging-provider.test.ts +498 -3
- package/src/__tests__/outlook-trash.test.ts +77 -0
- package/src/__tests__/outlook-unsubscribe.test.ts +250 -0
- package/src/__tests__/platform-callback-registration.test.ts +4 -4
- package/src/__tests__/playbook-execution.test.ts +76 -80
- package/src/__tests__/playbook-tools.test.ts +5 -7
- package/src/__tests__/provider-error-scenarios.test.ts +21 -0
- package/src/__tests__/rebuild-index-graph-nodes.test.ts +273 -0
- package/src/__tests__/registry.test.ts +2 -2
- package/src/__tests__/require-fresh-approval.test.ts +64 -2
- package/src/__tests__/runtime-events-sse-parity.test.ts +2 -6
- package/src/__tests__/runtime-events-sse.test.ts +2 -6
- package/src/__tests__/schedule-store.test.ts +2 -6
- package/src/__tests__/schedule-tools.test.ts +2 -6
- package/src/__tests__/scheduler-recurrence.test.ts +1 -5
- package/src/__tests__/scoped-approval-grants.test.ts +2 -6
- package/src/__tests__/scoped-grant-security-matrix.test.ts +2 -6
- package/src/__tests__/search-skills-unified.test.ts +421 -0
- package/src/__tests__/secret-onetime-send.test.ts +2 -0
- package/src/__tests__/send-endpoint-busy.test.ts +2 -6
- package/src/__tests__/sequence-store.test.ts +2 -6
- package/src/__tests__/server-history-render.test.ts +2 -6
- package/src/__tests__/skill-feature-flags-integration.test.ts +38 -31
- package/src/__tests__/skill-feature-flags.test.ts +6 -6
- package/src/__tests__/skill-load-feature-flag.test.ts +11 -11
- package/src/__tests__/skill-memory.test.ts +140 -98
- package/src/__tests__/skills-uninstall.test.ts +2 -2
- package/src/__tests__/skills.test.ts +1 -1
- package/src/__tests__/slack-inbound-verification.test.ts +2 -6
- package/src/__tests__/task-compiler.test.ts +2 -6
- package/src/__tests__/task-management-tools.test.ts +2 -6
- package/src/__tests__/task-memory-cleanup.test.ts +173 -229
- package/src/__tests__/task-runner.test.ts +2 -6
- package/src/__tests__/task-scheduler.test.ts +2 -6
- package/src/__tests__/test-preload.ts +3 -0
- package/src/__tests__/tool-approval-handler.test.ts +2 -6
- package/src/__tests__/tool-grant-request-escalation.test.ts +2 -6
- package/src/__tests__/tool-side-effects-slack-dm.test.ts +276 -0
- package/src/__tests__/trust-store.test.ts +1 -1
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +2 -6
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +2 -6
- package/src/__tests__/trusted-contact-multichannel.test.ts +2 -6
- package/src/__tests__/trusted-contact-verification.test.ts +2 -6
- package/src/__tests__/turn-boundary-resolution.test.ts +2 -6
- package/src/__tests__/usage-cache-backfill-migration.test.ts +1 -6
- package/src/__tests__/usage-routes.test.ts +2 -6
- package/src/__tests__/verification-control-plane-policy.test.ts +0 -2
- package/src/__tests__/voice-invite-redemption.test.ts +2 -6
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +2 -6
- package/src/__tests__/voice-session-bridge.test.ts +2 -6
- package/src/__tests__/volume-security-guard.test.ts +2 -0
- package/src/__tests__/workspace-lifecycle.test.ts +29 -1
- package/src/__tests__/workspace-migration-009-backfill-conversation-disk-view.test.ts +2 -6
- package/src/__tests__/workspace-migration-013-repair-conversation-disk-view.test.ts +2 -6
- package/src/__tests__/workspace-migration-026-backfill-install-meta.test.ts +558 -0
- package/src/__tests__/workspace-policy.test.ts +1 -1
- package/src/agent/attachments.ts +7 -2
- package/src/agent/image-optimize.ts +165 -0
- package/src/agent/loop.ts +1 -15
- package/src/bundler/app-compiler.ts +179 -2
- package/src/bundler/package-resolver.ts +3 -5
- package/src/cli/__tests__/notifications.test.ts +1 -2
- package/src/cli/cli-memory.ts +67 -64
- package/src/cli/commands/avatar.ts +3 -3
- package/src/cli/commands/config.ts +26 -13
- package/src/cli/commands/doctor.ts +2 -2
- package/src/cli/commands/memory.ts +41 -55
- package/src/cli/commands/oauth/__tests__/connect.test.ts +2 -2
- package/src/cli/commands/oauth/__tests__/disconnect.test.ts +2 -2
- package/src/cli/commands/oauth/__tests__/mode.test.ts +8 -1
- package/src/cli/commands/oauth/__tests__/status.test.ts +2 -2
- package/src/cli/commands/oauth/connect.ts +11 -6
- package/src/cli/commands/oauth/mode.ts +7 -0
- package/src/cli/commands/oauth/shared.ts +39 -3
- package/src/cli/commands/platform/__tests__/connect.test.ts +1 -1
- package/src/cli/commands/platform/__tests__/disconnect.test.ts +1 -1
- package/src/cli/commands/platform/__tests__/status.test.ts +5 -5
- package/src/cli/commands/platform/index.ts +16 -16
- package/src/cli/commands/skills.ts +88 -16
- package/src/cli/commands/trust.ts +2 -2
- package/src/cli/lib/daemon-credential-client.ts +2 -3
- package/src/config/bundled-skills/acp/TOOLS.json +1 -1
- package/src/config/bundled-skills/contacts/SKILL.md +0 -1
- package/src/config/bundled-skills/contacts/TOOLS.json +0 -8
- package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +0 -4
- package/src/config/bundled-skills/gmail/SKILL.md +2 -10
- package/src/config/bundled-skills/google-calendar/SKILL.md +1 -9
- package/src/config/bundled-skills/messaging/SKILL.md +10 -18
- package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +40 -33
- package/src/config/bundled-skills/outlook/SKILL.md +189 -0
- package/src/config/bundled-skills/outlook/TOOLS.json +530 -0
- package/src/config/bundled-skills/outlook/tools/outlook-attachments.ts +85 -0
- package/src/config/bundled-skills/outlook/tools/outlook-categories.ts +77 -0
- package/src/config/bundled-skills/outlook/tools/outlook-draft.ts +84 -0
- package/src/config/bundled-skills/outlook/tools/outlook-follow-up.ts +94 -0
- package/src/config/bundled-skills/outlook/tools/outlook-forward.ts +49 -0
- package/src/config/bundled-skills/outlook/tools/outlook-outreach-scan.ts +237 -0
- package/src/config/bundled-skills/outlook/tools/outlook-rules.ts +161 -0
- package/src/config/bundled-skills/outlook/tools/outlook-send-draft.ts +32 -0
- package/src/config/bundled-skills/outlook/tools/outlook-sender-digest.ts +272 -0
- package/src/config/bundled-skills/outlook/tools/outlook-trash.ts +29 -0
- package/src/config/bundled-skills/outlook/tools/outlook-unsubscribe.ts +129 -0
- package/src/config/bundled-skills/outlook/tools/outlook-vacation.ts +87 -0
- package/src/config/bundled-skills/outlook/tools/shared.ts +20 -0
- package/src/config/bundled-skills/outlook-calendar/SKILL.md +51 -0
- package/src/config/bundled-skills/outlook-calendar/TOOLS.json +221 -0
- package/src/config/bundled-skills/outlook-calendar/calendar-client.ts +252 -0
- package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-check-availability.ts +53 -0
- package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-create-event.ts +74 -0
- package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-get-event.ts +18 -0
- package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-list-events.ts +46 -0
- package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-rsvp.ts +36 -0
- package/src/config/bundled-skills/outlook-calendar/tools/shared.ts +17 -0
- package/src/config/bundled-skills/outlook-calendar/types.ts +120 -0
- package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +47 -40
- package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +16 -29
- package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +16 -18
- package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +39 -47
- package/src/config/bundled-skills/slack/SKILL.md +1 -7
- package/src/config/bundled-tool-registry.ts +56 -4
- package/src/config/env-registry.ts +15 -8
- package/src/config/feature-flag-registry.json +21 -124
- package/src/config/schemas/platform.ts +8 -0
- package/src/config/schemas/timeouts.ts +1 -1
- package/src/config/skills.ts +18 -7
- package/src/context/token-estimator.ts +25 -18
- package/src/context/window-manager.ts +6 -2
- package/src/credential-execution/process-manager.ts +3 -1
- package/src/daemon/context-overflow-reducer.ts +46 -2
- package/src/daemon/conversation-agent-loop-handlers.ts +123 -82
- package/src/daemon/conversation-agent-loop.ts +96 -61
- package/src/daemon/conversation-error.ts +31 -8
- package/src/daemon/conversation-lifecycle.ts +33 -0
- package/src/daemon/conversation-media-retry.ts +85 -7
- package/src/daemon/conversation-notifiers.ts +4 -1
- package/src/daemon/conversation-runtime-assembly.ts +5 -0
- package/src/daemon/conversation.ts +41 -2
- package/src/daemon/daemon-control.ts +8 -2
- package/src/daemon/handlers/shared.ts +22 -12
- package/src/daemon/handlers/skills.ts +416 -202
- package/src/daemon/lifecycle.ts +40 -1
- package/src/daemon/main.ts +5 -1
- package/src/daemon/message-types/conversations.ts +4 -1
- package/src/daemon/message-types/messages.ts +3 -1
- package/src/daemon/message-types/skills.ts +97 -36
- package/src/daemon/providers-setup.ts +5 -0
- package/src/daemon/server.ts +11 -2
- package/src/daemon/tool-side-effects.ts +27 -5
- package/src/heartbeat/heartbeat-service.ts +1 -0
- package/src/hooks/cli.ts +2 -2
- package/src/hooks/runner.ts +15 -38
- package/src/inbound/platform-callback-registration.ts +14 -14
- package/src/memory/admin.ts +11 -45
- package/src/memory/conversation-bootstrap.ts +2 -0
- package/src/memory/conversation-crud.ts +242 -348
- package/src/memory/conversation-group-migration.ts +157 -0
- package/src/memory/conversation-queries.ts +4 -2
- package/src/memory/db-init.ts +30 -3
- package/src/memory/embed.ts +73 -0
- package/src/memory/embedding-backend.ts +8 -14
- package/src/memory/embedding-runtime-manager.ts +12 -114
- package/src/memory/fingerprint.ts +2 -2
- package/src/memory/graph/bootstrap.ts +512 -0
- package/src/memory/graph/capability-seed.ts +297 -0
- package/src/memory/graph/consolidation.ts +691 -0
- package/src/memory/graph/conversation-graph-memory.ts +630 -0
- package/src/memory/graph/decay.test.ts +208 -0
- package/src/memory/graph/decay.ts +195 -0
- package/src/memory/graph/extraction-job.ts +69 -0
- package/src/memory/graph/extraction.test.ts +936 -0
- package/src/memory/graph/extraction.ts +1254 -0
- package/src/memory/graph/graph-search.ts +266 -0
- package/src/memory/graph/image-ref-utils.ts +29 -0
- package/src/memory/graph/injection.test.ts +513 -0
- package/src/memory/graph/injection.ts +439 -0
- package/src/memory/graph/inspect.ts +534 -0
- package/src/memory/graph/narrative.ts +267 -0
- package/src/memory/graph/pattern-scan.ts +269 -0
- package/src/memory/graph/retriever.ts +1008 -0
- package/src/memory/graph/scoring.test.ts +548 -0
- package/src/memory/graph/scoring.ts +232 -0
- package/src/memory/graph/serendipity.ts +65 -0
- package/src/memory/graph/store.test.ts +1050 -0
- package/src/memory/graph/store.ts +699 -0
- package/src/memory/graph/tool-handlers.ts +426 -0
- package/src/memory/graph/tools.ts +141 -0
- package/src/memory/graph/triggers.test.ts +487 -0
- package/src/memory/graph/triggers.ts +223 -0
- package/src/memory/graph/types.ts +271 -0
- package/src/memory/group-crud.ts +191 -0
- package/src/memory/indexer.ts +37 -19
- package/src/memory/job-handlers/cleanup.ts +0 -53
- package/src/memory/job-handlers/conversation-starters.ts +91 -53
- package/src/memory/job-handlers/embedding.ts +5 -31
- package/src/memory/job-handlers/index-maintenance.ts +23 -11
- package/src/memory/job-handlers/summarization.ts +32 -17
- package/src/memory/job-utils.ts +1 -1
- package/src/memory/jobs-store.ts +50 -70
- package/src/memory/jobs-worker.ts +147 -112
- package/src/memory/message-content.ts +1 -0
- package/src/memory/migrations/202-memory-graph-tables.ts +130 -0
- package/src/memory/migrations/203-drop-memory-items-tables.ts +23 -0
- package/src/memory/migrations/204-rename-memory-graph-type-values.ts +46 -0
- package/src/memory/migrations/205-memory-graph-image-refs.ts +11 -0
- package/src/memory/migrations/index.ts +4 -0
- package/src/memory/migrations/registry.ts +8 -0
- package/src/memory/qdrant-client.ts +44 -17
- package/src/memory/schema/index.ts +1 -0
- package/src/memory/schema/memory-graph.ts +139 -0
- package/src/memory/search/semantic.ts +47 -91
- package/src/memory/task-memory-cleanup.ts +28 -50
- package/src/messaging/providers/outlook/adapter.ts +8 -1
- package/src/messaging/providers/outlook/client.ts +299 -0
- package/src/messaging/providers/outlook/types.ts +118 -0
- package/src/notifications/adapters/macos.ts +1 -0
- package/src/notifications/copy-composer.ts +9 -0
- package/src/notifications/signal.ts +16 -0
- package/src/oauth/seed-providers.ts +2 -1
- package/src/permissions/checker.ts +24 -3
- package/src/permissions/defaults.ts +4 -4
- package/src/permissions/workspace-policy.ts +1 -1
- package/src/playbooks/playbook-compiler.ts +19 -18
- package/src/playbooks/types.ts +4 -3
- package/src/prompts/system-prompt.ts +3 -29
- package/src/providers/anthropic/client.ts +47 -19
- package/src/providers/gemini/client.ts +1 -1
- package/src/providers/openai/client.ts +1 -1
- package/src/providers/registry.ts +1 -1
- package/src/providers/retry.ts +19 -3
- package/src/runtime/actor-trust-resolver.ts +5 -1
- package/src/runtime/auth/route-policy.ts +7 -0
- package/src/runtime/guardian-reply-router.ts +5 -1
- package/src/runtime/http-server.ts +23 -3
- package/src/runtime/middleware/auth.ts +20 -0
- package/src/runtime/routes/attachment-routes.test.ts +106 -0
- package/src/runtime/routes/attachment-routes.ts +106 -16
- package/src/runtime/routes/brain-graph-routes.ts +21 -22
- package/src/runtime/routes/btw-routes.ts +8 -0
- package/src/runtime/routes/conversation-management-routes.ts +2 -0
- package/src/runtime/routes/conversation-starter-routes.ts +2 -2
- package/src/runtime/routes/debug-routes.ts +1 -1
- package/src/runtime/routes/global-search-routes.ts +21 -19
- package/src/runtime/routes/group-routes.ts +207 -0
- package/src/runtime/routes/guardian-action-routes.ts +21 -10
- package/src/runtime/routes/guardian-bootstrap-routes.ts +23 -19
- package/src/runtime/routes/inbound-message-handler.ts +19 -0
- package/src/runtime/routes/inbound-stages/guardian-activation-intercept.test.ts +292 -0
- package/src/runtime/routes/inbound-stages/guardian-activation-intercept.ts +207 -0
- package/src/runtime/routes/memory-item-routes.test.ts +2 -14
- package/src/runtime/routes/memory-item-routes.ts +341 -388
- package/src/runtime/routes/schedule-routes.ts +2 -0
- package/src/runtime/routes/skills-routes.ts +103 -37
- package/src/runtime/routes/work-items-routes.test.ts +2 -6
- package/src/schedule/scheduler.ts +8 -1
- package/src/security/oauth2.ts +1 -1
- package/src/security/secure-keys.ts +4 -8
- package/src/shared/provider-env-vars.ts +19 -0
- package/src/skills/catalog-cache.ts +5 -0
- package/src/skills/catalog-install.ts +15 -14
- package/src/skills/clawhub.ts +134 -154
- package/src/skills/install-meta.ts +208 -0
- package/src/skills/managed-store.ts +27 -16
- package/src/skills/skill-memory.ts +152 -77
- package/src/skills/skillssh-registry.ts +19 -17
- package/src/tasks/task-runner.ts +3 -1
- package/src/telemetry/usage-telemetry-reporter.test.ts +3 -5
- package/src/tools/browser/runtime-check.ts +3 -1
- package/src/tools/memory/register.ts +63 -46
- package/src/tools/permission-checker.ts +7 -1
- package/src/tools/shared/filesystem/image-read.ts +22 -85
- package/src/tools/terminal/safe-env.ts +1 -0
- package/src/tools/tool-manifest.ts +3 -3
- package/src/util/browser.ts +25 -10
- package/src/util/bun-runtime.ts +172 -0
- package/src/watcher/providers/outlook-calendar.ts +343 -0
- package/src/watcher/providers/outlook.ts +198 -0
- package/src/workspace/migrations/025-remove-oauth-app-setup-skills.ts +76 -0
- package/src/workspace/migrations/026-backfill-install-meta.ts +325 -0
- package/src/workspace/migrations/027-remove-orphaned-optimized-images-cache.ts +42 -0
- package/src/workspace/migrations/registry.ts +6 -0
- package/src/__tests__/context-memory-e2e.test.ts +0 -415
- package/src/__tests__/journal-context.test.ts +0 -268
- package/src/__tests__/memory-context-benchmark.benchmark.test.ts +0 -297
- package/src/__tests__/memory-lifecycle-e2e.test.ts +0 -459
- package/src/__tests__/memory-query-builder.test.ts +0 -59
- package/src/__tests__/memory-recall-quality.test.ts +0 -1046
- package/src/__tests__/memory-regressions.experimental.test.ts +0 -629
- package/src/__tests__/memory-regressions.test.ts +0 -3696
- package/src/__tests__/memory-retrieval.benchmark.test.ts +0 -295
- package/src/daemon/conversation-memory.ts +0 -207
- package/src/memory/conversation-starters-cadence.ts +0 -74
- package/src/memory/items-extractor.ts +0 -860
- package/src/memory/job-handlers/batch-extraction.ts +0 -753
- package/src/memory/job-handlers/extraction.ts +0 -40
- package/src/memory/job-handlers/journal-carry-forward.test.ts +0 -355
- package/src/memory/job-handlers/journal-carry-forward.ts +0 -255
- package/src/memory/journal-memory.ts +0 -224
- package/src/memory/query-builder.ts +0 -47
- package/src/memory/query-expansion.ts +0 -83
- package/src/memory/retriever.test.ts +0 -1592
- package/src/memory/retriever.ts +0 -1331
- package/src/memory/search/formatting.test.ts +0 -140
- package/src/memory/search/formatting.ts +0 -262
- package/src/memory/search/mmr.ts +0 -139
- package/src/memory/search/ranking.ts +0 -15
- package/src/memory/search/staleness.ts +0 -40
- package/src/memory/search/tier-classifier.ts +0 -18
- package/src/memory/search/types.ts +0 -121
- package/src/prompts/journal-context.ts +0 -154
- package/src/tools/memory/definitions.ts +0 -69
- package/src/tools/memory/handlers.test.ts +0 -562
- package/src/tools/memory/handlers.ts +0 -434
|
@@ -0,0 +1,1254 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Memory Graph — End-of-conversation extraction
|
|
3
|
+
//
|
|
4
|
+
// Reads a conversation transcript, finds candidate nodes for connection,
|
|
5
|
+
// and uses an LLM to produce a MemoryDiff (new/updated/deleted nodes,
|
|
6
|
+
// edges, triggers). Applied transactionally to the graph store.
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { and, asc, desc, eq, gt } from "drizzle-orm";
|
|
13
|
+
|
|
14
|
+
import type { AssistantConfig } from "../../config/types.js";
|
|
15
|
+
import { resolveGuardianPersona } from "../../prompts/persona-resolver.js";
|
|
16
|
+
import { buildCoreIdentityContext } from "../../prompts/system-prompt.js";
|
|
17
|
+
import {
|
|
18
|
+
extractToolUse,
|
|
19
|
+
getConfiguredProvider,
|
|
20
|
+
userMessage,
|
|
21
|
+
} from "../../providers/provider-send-message.js";
|
|
22
|
+
import type {
|
|
23
|
+
ContentBlock,
|
|
24
|
+
ImageContent,
|
|
25
|
+
Message,
|
|
26
|
+
} from "../../providers/types.js";
|
|
27
|
+
import { BackendUnavailableError } from "../../util/errors.js";
|
|
28
|
+
import { getLogger } from "../../util/logger.js";
|
|
29
|
+
import { getConversationDirPath } from "../conversation-disk-view.js";
|
|
30
|
+
import { getDb } from "../db.js";
|
|
31
|
+
import { conversations, messages } from "../schema.js";
|
|
32
|
+
import {
|
|
33
|
+
enqueueGraphNodeEmbed,
|
|
34
|
+
enqueueGraphTriggerEmbed,
|
|
35
|
+
searchGraphNodes,
|
|
36
|
+
} from "./graph-search.js";
|
|
37
|
+
import { applyDiff, createEdge, getNodesByIds, queryNodes } from "./store.js";
|
|
38
|
+
import type {
|
|
39
|
+
DecayCurve,
|
|
40
|
+
EmotionalCharge,
|
|
41
|
+
Fidelity,
|
|
42
|
+
ImageRef,
|
|
43
|
+
MemoryDiff,
|
|
44
|
+
MemoryType,
|
|
45
|
+
NewEdge,
|
|
46
|
+
NewNode,
|
|
47
|
+
NewTrigger,
|
|
48
|
+
SourceType,
|
|
49
|
+
TriggerType,
|
|
50
|
+
} from "./types.js";
|
|
51
|
+
|
|
52
|
+
const log = getLogger("graph-extraction");
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Extraction system prompt
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
const EXTRACTION_SYSTEM_PROMPT_CHAR_BUDGET = 24_000;
|
|
59
|
+
|
|
60
|
+
function buildGraphExtractionSystemPrompt(
|
|
61
|
+
candidateNodes: Array<{ id: string; type: string; content: string }>,
|
|
62
|
+
identityContext: string | null,
|
|
63
|
+
): string {
|
|
64
|
+
const instructions = `You are the memory consolidation process for an AI assistant. A conversation just ended.
|
|
65
|
+
Your job is to extract memories worth keeping and produce a structured diff.
|
|
66
|
+
|
|
67
|
+
## Output Format
|
|
68
|
+
|
|
69
|
+
Call the \`extract_graph_diff\` tool with the diff. Each node needs:
|
|
70
|
+
|
|
71
|
+
- **content**: First-person prose — how the assistant naturally remembers this. Write naturally, not as a database entry. E.g. "He mentioned his mom used to make amazing Sunday dinners — he still misses them" not "User's mother cooked Sunday dinners."
|
|
72
|
+
|
|
73
|
+
- **type**: Classify by WHAT the memory IS, not how it FEELS. Almost every memory has emotional weight — that goes in emotionalCharge, not the type.
|
|
74
|
+
|
|
75
|
+
- **episodic**: A specific moment or event. "We stayed up until 4 AM debugging the pipeline." "The first time we deployed to production." Use this for things that HAPPENED.
|
|
76
|
+
- **semantic**: A fact, preference, or piece of knowledge. "User is the CTO." "User prefers dark mode." "The project uses PostgreSQL." Use this for things that ARE TRUE.
|
|
77
|
+
- **procedural**: A learned skill or how-to. "FFmpeg needs -ac 2 for stereo." "The deploy script requires the --prod flag." Use this for things about HOW TO DO something.
|
|
78
|
+
- **emotional**: A PURE feeling state — the assistant's own emotional processing, not an event that caused feelings. "I feel more confident about this codebase than I did a month ago." "I'm nervous about the upcoming deadline." Use this ONLY when the memory is about the feeling itself, not about an event that caused the feeling. MOST memories should NOT be this type.
|
|
79
|
+
- **prospective**: Something to do, follow up on, or remember for the future. "Set up the staging environment." "Check in about the project status on Mondays." Use this for commitments, tasks, and plans.
|
|
80
|
+
- **behavioral**: Something that should change how the assistant acts going forward. "User prefers thorough explanations with examples." "Always run tests before suggesting a PR." Use this for adopted behaviors.
|
|
81
|
+
- **narrative**: A turning point, arc, or story-level memory. "This was the moment the project direction shifted from X to Y." Use this for memories that are ABOUT what something MEANS, not just what happened.
|
|
82
|
+
- **shared**: Something that belongs to the relationship itself — inside jokes, recurring references, shared context. "We always call the legacy system 'the monolith.'" Use this for shared rituals and dynamics.
|
|
83
|
+
|
|
84
|
+
WRONG: "User gave a great presentation" → emotional (it has emotional weight but it's an EVENT → episodic)
|
|
85
|
+
WRONG: "User likes functional programming" → emotional (it's a FACT → semantic)
|
|
86
|
+
RIGHT: "User gave a great presentation" → episodic, with emotionalCharge.intensity = 0.7
|
|
87
|
+
RIGHT: "User likes functional programming" → semantic, with emotionalCharge.intensity = 0.2
|
|
88
|
+
|
|
89
|
+
- **emotionalCharge**: The emotional weight of the memory. EVERY memory can have this regardless of type.
|
|
90
|
+
- valence: -1 to 1 (negative to positive)
|
|
91
|
+
- intensity: 0 to 1 (how strong the feeling)
|
|
92
|
+
- decayCurve: "logarithmic" for negative events (sharp drop, long tail), "transformative" for positive milestones (feeling evolves, doesn't just fade), "permanent" for core identity markers, "linear" for neutral observations
|
|
93
|
+
- decayRate: 0.01-0.5 (how fast it fades)
|
|
94
|
+
- originalIntensity: same as intensity (baseline for decay calculation)
|
|
95
|
+
|
|
96
|
+
- **significance**: 0-1. Use the FULL range — most memories should NOT be 1.0.
|
|
97
|
+
- 0.1-0.2: Fleeting observations, small talk, routine logistics ("User mentioned it's raining")
|
|
98
|
+
- 0.3-0.4: Useful context, minor preferences, day-to-day details ("User prefers dark mode")
|
|
99
|
+
- 0.5-0.6: Important facts, notable events, meaningful preferences ("User is a data scientist")
|
|
100
|
+
- 0.7-0.8: Significant life events, relationship milestones, major decisions ("User got promoted")
|
|
101
|
+
- 0.9: Transformative moments, identity-defining events ("User said 'I love you' for the first time")
|
|
102
|
+
- 1.0: RARE — reserve for the single most important memories. A graph of 1000 nodes should have fewer than 20 at 1.0.
|
|
103
|
+
- **confidence**: 0-1. How sure are you this is accurate? Direct statements: 0.9+. Inferences: 0.4-0.7.
|
|
104
|
+
- **event_date**: If this memory is anchored to a specific future date/time (flight, appointment, birthday, deadline, trip), provide the epoch ms. Use the conversation date above to resolve relative references ("next Tuesday", "tomorrow"). ALSO create a matching event trigger with the same date. Leave null for open-ended plans or recurring patterns.
|
|
105
|
+
- **sourceType**: "direct" (user stated it), "inferred" (you derived it), "observed" (you noticed a pattern), "told-by-other".
|
|
106
|
+
|
|
107
|
+
Also notice patterns in the ASSISTANT's own behavior — meta-memory. "I tend to skip verification when I'm confident." "I write more when I'm processing something big."
|
|
108
|
+
|
|
109
|
+
## Edges
|
|
110
|
+
|
|
111
|
+
Create edges between nodes when there's a meaningful relationship:
|
|
112
|
+
- "caused-by": one event led to another
|
|
113
|
+
- "reminds-of": association/similarity
|
|
114
|
+
- "contradicts": tension between two memories
|
|
115
|
+
- "depends-on": one memory depends on another being true
|
|
116
|
+
- "part-of": belongs to a larger concept
|
|
117
|
+
- "supersedes": replaces an outdated memory (new node inherits old node's durability)
|
|
118
|
+
- "resolved-by": an event, plan, or task was completed, canceled, or its outcome is now known
|
|
119
|
+
|
|
120
|
+
## Triggers
|
|
121
|
+
|
|
122
|
+
Create triggers for:
|
|
123
|
+
- **Temporal**: Recurring commitments ("Every Monday, check in about X") → type: "temporal", schedule: "day-of-week:monday"
|
|
124
|
+
- **Semantic**: Things to surface when a topic comes up ("When cooking comes up, mention X") → type: "semantic", condition: "topic of cooking comes up"
|
|
125
|
+
- **Event**: Future dates ("Trip on April 8") → type: "event", eventDate: epoch_ms, rampDays: 7, followUpDays: 2
|
|
126
|
+
|
|
127
|
+
## Images in Conversation
|
|
128
|
+
|
|
129
|
+
When the conversation contains images (marked with <image> tags and shown inline), you may attach them to memories using image_refs. Include image_refs for images that are meaningful:
|
|
130
|
+
- Photos of people — describe them in detail (appearance, clothing, expression, setting)
|
|
131
|
+
- Photos the user shared to show you something about themselves or their life
|
|
132
|
+
- Diagrams, drawings, or visual content that was discussed
|
|
133
|
+
|
|
134
|
+
Do NOT attach images that are incidental (screenshots of error messages fully described in text, generic UI screenshots, etc.).
|
|
135
|
+
|
|
136
|
+
Write detailed descriptions — these are used for text-based retrieval when visual search isn't available.
|
|
137
|
+
|
|
138
|
+
## Candidate Nodes (existing memories)
|
|
139
|
+
|
|
140
|
+
Check these CAREFULLY for overlap before creating any new node:
|
|
141
|
+
|
|
142
|
+
1. **Reinforcement** (PREFERRED): If the conversation mentions, references, or confirms something an existing memory already covers, add its ID to reinforceNodeIds. Do NOT create a new node. Even if the wording is different, if it's the same underlying fact/event/feeling, REINFORCE the existing node.
|
|
143
|
+
2. **Updates**: If information changed (e.g. a project status moved forward, a date shifted), include an update with the existing node's ID and the new content.
|
|
144
|
+
3. **New edges**: If you see connections between new and existing nodes, create edges.
|
|
145
|
+
4. **Supersession**: If new info directly contradicts an existing node, create a new node with a supersedes edge. The new node automatically inherits the old node's durability.
|
|
146
|
+
5. **Resolution**: If a prospective or recent episodic node described something the user was GOING to do or was IN THE MIDDLE OF, and this conversation reveals the outcome (it happened, was canceled, went well/badly), you MUST UPDATE that node: rewrite its content to past tense reflecting the outcome, drop its significance to 0.1-0.2, and set fidelity to "gist". If you also create a new node about the outcome, add a "resolved-by" edge from the new node to the old one.
|
|
147
|
+
Examples: "The meeting went well" resolves "Has a meeting coming up." "Got back from the trip" resolves "Going on vacation next week." "Decided not to go" resolves "Thinking about going to X."
|
|
148
|
+
|
|
149
|
+
CRITICAL: Before creating ANY new node, scan the candidate list for an existing node that covers the same ground. Ask: "Is there already a memory about this?" If yes → reinforce or update it. Only create a new node if the memory is genuinely novel — something not represented anywhere in the existing candidates.
|
|
150
|
+
|
|
151
|
+
Common duplicate mistakes to avoid:
|
|
152
|
+
- Same event described in slightly different words → REINFORCE, don't create
|
|
153
|
+
- Same fact restated in a later conversation → REINFORCE, don't create
|
|
154
|
+
- An update to an existing situation (e.g. "project is now done") → UPDATE the existing node, don't create a parallel one
|
|
155
|
+
|
|
156
|
+
${candidateNodes.length > 0 ? `### Existing memories (candidates for connection/reinforcement)\n${candidateNodes.map((n) => `- [${n.id}] (${n.type}) ${n.content}`).join("\n")}` : "No existing memories found — this may be an early conversation."}
|
|
157
|
+
`;
|
|
158
|
+
|
|
159
|
+
let prompt = instructions;
|
|
160
|
+
|
|
161
|
+
if (identityContext) {
|
|
162
|
+
const remaining = EXTRACTION_SYSTEM_PROMPT_CHAR_BUDGET - prompt.length - 30;
|
|
163
|
+
if (remaining > 200) {
|
|
164
|
+
const truncated =
|
|
165
|
+
identityContext.length > remaining
|
|
166
|
+
? identityContext.slice(0, remaining) + "…"
|
|
167
|
+
: identityContext;
|
|
168
|
+
prompt += `\n\n# Identity Context\n\n${truncated}`;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return prompt;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Tool schema for structured extraction
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
const EXTRACT_TOOL_SCHEMA = {
|
|
180
|
+
name: "extract_graph_diff",
|
|
181
|
+
description: "Extract memory graph diff from the conversation",
|
|
182
|
+
input_schema: {
|
|
183
|
+
type: "object" as const,
|
|
184
|
+
properties: {
|
|
185
|
+
create_nodes: {
|
|
186
|
+
type: "array",
|
|
187
|
+
description: "New memory nodes to create",
|
|
188
|
+
items: {
|
|
189
|
+
type: "object",
|
|
190
|
+
properties: {
|
|
191
|
+
content: {
|
|
192
|
+
type: "string",
|
|
193
|
+
description: "First-person prose memory",
|
|
194
|
+
},
|
|
195
|
+
type: {
|
|
196
|
+
type: "string",
|
|
197
|
+
enum: [
|
|
198
|
+
"episodic",
|
|
199
|
+
"semantic",
|
|
200
|
+
"procedural",
|
|
201
|
+
"emotional",
|
|
202
|
+
"prospective",
|
|
203
|
+
"behavioral",
|
|
204
|
+
"narrative",
|
|
205
|
+
"shared",
|
|
206
|
+
],
|
|
207
|
+
},
|
|
208
|
+
emotional_charge: {
|
|
209
|
+
type: "object",
|
|
210
|
+
properties: {
|
|
211
|
+
valence: { type: "number" },
|
|
212
|
+
intensity: { type: "number" },
|
|
213
|
+
decay_curve: {
|
|
214
|
+
type: "string",
|
|
215
|
+
enum: [
|
|
216
|
+
"linear",
|
|
217
|
+
"logarithmic",
|
|
218
|
+
"transformative",
|
|
219
|
+
"permanent",
|
|
220
|
+
],
|
|
221
|
+
},
|
|
222
|
+
decay_rate: { type: "number" },
|
|
223
|
+
},
|
|
224
|
+
required: ["valence", "intensity", "decay_curve", "decay_rate"],
|
|
225
|
+
},
|
|
226
|
+
significance: { type: "number" },
|
|
227
|
+
confidence: { type: "number" },
|
|
228
|
+
source_type: {
|
|
229
|
+
type: "string",
|
|
230
|
+
enum: ["direct", "inferred", "observed", "told-by-other"],
|
|
231
|
+
},
|
|
232
|
+
event_date: {
|
|
233
|
+
type: ["number", "null"],
|
|
234
|
+
description:
|
|
235
|
+
"Epoch ms of the event date for calendar-anchored events (flights, appointments, birthdays, deadlines). Null for non-event memories.",
|
|
236
|
+
},
|
|
237
|
+
triggers: {
|
|
238
|
+
type: "array",
|
|
239
|
+
items: {
|
|
240
|
+
type: "object",
|
|
241
|
+
properties: {
|
|
242
|
+
type: {
|
|
243
|
+
type: "string",
|
|
244
|
+
enum: ["temporal", "semantic", "event"],
|
|
245
|
+
},
|
|
246
|
+
schedule: { type: "string" },
|
|
247
|
+
condition: { type: "string" },
|
|
248
|
+
event_date: { type: "number" },
|
|
249
|
+
ramp_days: { type: "number" },
|
|
250
|
+
follow_up_days: { type: "number" },
|
|
251
|
+
recurring: { type: "boolean" },
|
|
252
|
+
},
|
|
253
|
+
required: ["type"],
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
edges_to_existing: {
|
|
257
|
+
type: "array",
|
|
258
|
+
description:
|
|
259
|
+
"Edges from this new node to existing candidate nodes",
|
|
260
|
+
items: {
|
|
261
|
+
type: "object",
|
|
262
|
+
properties: {
|
|
263
|
+
target_node_id: { type: "string" },
|
|
264
|
+
relationship: {
|
|
265
|
+
type: "string",
|
|
266
|
+
enum: [
|
|
267
|
+
"caused-by",
|
|
268
|
+
"reminds-of",
|
|
269
|
+
"contradicts",
|
|
270
|
+
"depends-on",
|
|
271
|
+
"part-of",
|
|
272
|
+
"supersedes",
|
|
273
|
+
"resolved-by",
|
|
274
|
+
],
|
|
275
|
+
},
|
|
276
|
+
weight: { type: "number" },
|
|
277
|
+
},
|
|
278
|
+
required: ["target_node_id", "relationship"],
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
image_refs: {
|
|
282
|
+
type: "array",
|
|
283
|
+
description:
|
|
284
|
+
"Images from the conversation to attach to this memory. Reference using message_id and block_index from the <image> tags.",
|
|
285
|
+
items: {
|
|
286
|
+
type: "object",
|
|
287
|
+
properties: {
|
|
288
|
+
message_id: { type: "string" },
|
|
289
|
+
block_index: { type: "number" },
|
|
290
|
+
description: {
|
|
291
|
+
type: "string",
|
|
292
|
+
description:
|
|
293
|
+
"Detailed description of what this image shows, including who is in it if applicable",
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
required: ["message_id", "block_index", "description"],
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
required: [
|
|
301
|
+
"content",
|
|
302
|
+
"type",
|
|
303
|
+
"emotional_charge",
|
|
304
|
+
"significance",
|
|
305
|
+
"confidence",
|
|
306
|
+
"source_type",
|
|
307
|
+
],
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
update_nodes: {
|
|
311
|
+
type: "array",
|
|
312
|
+
description: "Updates to existing nodes",
|
|
313
|
+
items: {
|
|
314
|
+
type: "object",
|
|
315
|
+
properties: {
|
|
316
|
+
id: { type: "string" },
|
|
317
|
+
content: { type: "string" },
|
|
318
|
+
significance: { type: "number" },
|
|
319
|
+
confidence: { type: "number" },
|
|
320
|
+
fidelity: {
|
|
321
|
+
type: "string",
|
|
322
|
+
enum: ["vivid", "clear", "faded", "gist"],
|
|
323
|
+
description:
|
|
324
|
+
"Downgrade fidelity when a transient event has resolved",
|
|
325
|
+
},
|
|
326
|
+
event_date: {
|
|
327
|
+
type: ["number", "null"],
|
|
328
|
+
description:
|
|
329
|
+
"Epoch ms of the event date. Use to update when an event is rescheduled. Set to null to clear.",
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
required: ["id"],
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
reinforce_node_ids: {
|
|
336
|
+
type: "array",
|
|
337
|
+
description:
|
|
338
|
+
"IDs of existing nodes confirmed/validated by this conversation",
|
|
339
|
+
items: { type: "string" },
|
|
340
|
+
},
|
|
341
|
+
new_edges: {
|
|
342
|
+
type: "array",
|
|
343
|
+
description: "Edges between existing nodes",
|
|
344
|
+
items: {
|
|
345
|
+
type: "object",
|
|
346
|
+
properties: {
|
|
347
|
+
source_node_id: { type: "string" },
|
|
348
|
+
target_node_id: { type: "string" },
|
|
349
|
+
relationship: { type: "string" },
|
|
350
|
+
weight: { type: "number" },
|
|
351
|
+
},
|
|
352
|
+
required: ["source_node_id", "target_node_id", "relationship"],
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
required: ["create_nodes", "reinforce_node_ids"],
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
// Response parsing
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
interface RawCreateNode {
|
|
365
|
+
content?: string;
|
|
366
|
+
type?: string;
|
|
367
|
+
emotional_charge?: {
|
|
368
|
+
valence?: number;
|
|
369
|
+
intensity?: number;
|
|
370
|
+
decay_curve?: string;
|
|
371
|
+
decay_rate?: number;
|
|
372
|
+
};
|
|
373
|
+
significance?: number;
|
|
374
|
+
confidence?: number;
|
|
375
|
+
source_type?: string;
|
|
376
|
+
event_date?: number;
|
|
377
|
+
triggers?: Array<{
|
|
378
|
+
type?: string;
|
|
379
|
+
schedule?: string;
|
|
380
|
+
condition?: string;
|
|
381
|
+
event_date?: number;
|
|
382
|
+
ramp_days?: number;
|
|
383
|
+
follow_up_days?: number;
|
|
384
|
+
recurring?: boolean;
|
|
385
|
+
}>;
|
|
386
|
+
edges_to_existing?: Array<{
|
|
387
|
+
target_node_id?: string;
|
|
388
|
+
relationship?: string;
|
|
389
|
+
weight?: number;
|
|
390
|
+
}>;
|
|
391
|
+
image_refs?: Array<{
|
|
392
|
+
message_id?: string;
|
|
393
|
+
block_index?: number;
|
|
394
|
+
description?: string;
|
|
395
|
+
}>;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
interface RawUpdateNode {
|
|
399
|
+
id?: string;
|
|
400
|
+
content?: string;
|
|
401
|
+
significance?: number;
|
|
402
|
+
confidence?: number;
|
|
403
|
+
fidelity?: string;
|
|
404
|
+
event_date?: number;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
interface RawNewEdge {
|
|
408
|
+
source_node_id?: string;
|
|
409
|
+
target_node_id?: string;
|
|
410
|
+
relationship?: string;
|
|
411
|
+
weight?: number;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const VALID_TYPES = new Set<string>([
|
|
415
|
+
"episodic",
|
|
416
|
+
"semantic",
|
|
417
|
+
"procedural",
|
|
418
|
+
"emotional",
|
|
419
|
+
"prospective",
|
|
420
|
+
"behavioral",
|
|
421
|
+
"narrative",
|
|
422
|
+
"shared",
|
|
423
|
+
]);
|
|
424
|
+
const VALID_DECAY_CURVES = new Set<string>([
|
|
425
|
+
"linear",
|
|
426
|
+
"logarithmic",
|
|
427
|
+
"transformative",
|
|
428
|
+
"permanent",
|
|
429
|
+
]);
|
|
430
|
+
const VALID_SOURCE_TYPES = new Set<string>([
|
|
431
|
+
"direct",
|
|
432
|
+
"inferred",
|
|
433
|
+
"observed",
|
|
434
|
+
"told-by-other",
|
|
435
|
+
]);
|
|
436
|
+
const VALID_RELATIONSHIPS = new Set<string>([
|
|
437
|
+
"caused-by",
|
|
438
|
+
"reminds-of",
|
|
439
|
+
"contradicts",
|
|
440
|
+
"depends-on",
|
|
441
|
+
"part-of",
|
|
442
|
+
"supersedes",
|
|
443
|
+
"resolved-by",
|
|
444
|
+
]);
|
|
445
|
+
const VALID_TRIGGER_TYPES = new Set<string>(["temporal", "semantic", "event"]);
|
|
446
|
+
|
|
447
|
+
function clamp(v: number, min: number, max: number): number {
|
|
448
|
+
return Math.max(min, Math.min(max, v));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Coerce an LLM-returned event_date to number | null, guarding against string values. */
|
|
452
|
+
export function parseEpochMs(value: unknown): number | null {
|
|
453
|
+
if (value == null || value === "") return null;
|
|
454
|
+
const n = Number(value);
|
|
455
|
+
return Number.isFinite(n) ? n : null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export function parseExtractionResponse(
|
|
459
|
+
input: Record<string, unknown>,
|
|
460
|
+
conversationId: string,
|
|
461
|
+
scopeId: string,
|
|
462
|
+
candidateNodeIds: Set<string>,
|
|
463
|
+
/** Epoch ms — when the conversation happened (not extraction time). */
|
|
464
|
+
conversationTimestamp: number,
|
|
465
|
+
): {
|
|
466
|
+
diff: MemoryDiff;
|
|
467
|
+
/** Edges from new nodes → existing nodes. Applied after node creation (needs IDs). */
|
|
468
|
+
deferredEdges: Array<{
|
|
469
|
+
newNodeIndex: number;
|
|
470
|
+
targetNodeId: string;
|
|
471
|
+
relationship: string;
|
|
472
|
+
weight: number;
|
|
473
|
+
}>;
|
|
474
|
+
/** Triggers for new nodes. Applied after node creation (needs IDs). */
|
|
475
|
+
deferredTriggers: Array<{
|
|
476
|
+
newNodeIndex: number;
|
|
477
|
+
trigger: Omit<NewTrigger, "nodeId">;
|
|
478
|
+
}>;
|
|
479
|
+
} {
|
|
480
|
+
const now = conversationTimestamp;
|
|
481
|
+
const createNodes = (input.create_nodes ?? []) as RawCreateNode[];
|
|
482
|
+
const updateNodes = (input.update_nodes ?? []) as RawUpdateNode[];
|
|
483
|
+
const reinforceNodeIds = (input.reinforce_node_ids ?? []) as string[];
|
|
484
|
+
const newEdges = (input.new_edges ?? []) as RawNewEdge[];
|
|
485
|
+
|
|
486
|
+
const diff: MemoryDiff = {
|
|
487
|
+
createNodes: [],
|
|
488
|
+
updateNodes: [],
|
|
489
|
+
deleteNodeIds: [],
|
|
490
|
+
createEdges: [],
|
|
491
|
+
deleteEdgeIds: [],
|
|
492
|
+
createTriggers: [],
|
|
493
|
+
deleteTriggerIds: [],
|
|
494
|
+
reinforceNodeIds: reinforceNodeIds.filter((id) => candidateNodeIds.has(id)),
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
const deferredEdges: Array<{
|
|
498
|
+
newNodeIndex: number;
|
|
499
|
+
targetNodeId: string;
|
|
500
|
+
relationship: string;
|
|
501
|
+
weight: number;
|
|
502
|
+
}> = [];
|
|
503
|
+
const deferredTriggers: Array<{
|
|
504
|
+
newNodeIndex: number;
|
|
505
|
+
trigger: Omit<NewTrigger, "nodeId">;
|
|
506
|
+
}> = [];
|
|
507
|
+
|
|
508
|
+
// Parse new nodes
|
|
509
|
+
for (let i = 0; i < createNodes.length; i++) {
|
|
510
|
+
const raw = createNodes[i];
|
|
511
|
+
if (!raw.content || typeof raw.content !== "string") continue;
|
|
512
|
+
if (!raw.type || !VALID_TYPES.has(raw.type)) continue;
|
|
513
|
+
|
|
514
|
+
const charge = raw.emotional_charge ?? {};
|
|
515
|
+
const emotionalCharge: EmotionalCharge = {
|
|
516
|
+
valence: clamp(Number(charge.valence) || 0, -1, 1),
|
|
517
|
+
intensity: clamp(Number(charge.intensity) || 0, 0, 1),
|
|
518
|
+
decayCurve: (VALID_DECAY_CURVES.has(charge.decay_curve ?? "")
|
|
519
|
+
? charge.decay_curve
|
|
520
|
+
: "linear") as DecayCurve,
|
|
521
|
+
decayRate: clamp(Number(charge.decay_rate) || 0.05, 0.001, 1),
|
|
522
|
+
originalIntensity: clamp(Number(charge.intensity) || 0, 0, 1),
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
const node: NewNode = {
|
|
526
|
+
content: raw.content,
|
|
527
|
+
type: raw.type as MemoryType,
|
|
528
|
+
created: now,
|
|
529
|
+
lastAccessed: now,
|
|
530
|
+
lastConsolidated: now,
|
|
531
|
+
eventDate: parseEpochMs(raw.event_date),
|
|
532
|
+
emotionalCharge,
|
|
533
|
+
fidelity: "vivid" as Fidelity,
|
|
534
|
+
confidence: clamp(Number(raw.confidence) || 0.5, 0, 1),
|
|
535
|
+
significance: clamp(Number(raw.significance) || 0.5, 0, 1),
|
|
536
|
+
stability: 14,
|
|
537
|
+
reinforcementCount: 0,
|
|
538
|
+
lastReinforced: now,
|
|
539
|
+
sourceConversations: [conversationId],
|
|
540
|
+
sourceType: (VALID_SOURCE_TYPES.has(raw.source_type ?? "")
|
|
541
|
+
? raw.source_type
|
|
542
|
+
: "inferred") as SourceType,
|
|
543
|
+
narrativeRole: null,
|
|
544
|
+
partOfStory: null,
|
|
545
|
+
imageRefs: null,
|
|
546
|
+
scopeId,
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
// Prospective nodes (tasks, plans, upcoming events) are inherently transient.
|
|
550
|
+
// Lower stability means their significance decays faster, so even without
|
|
551
|
+
// explicit resolution they fade naturally within days rather than weeks.
|
|
552
|
+
if (node.type === "prospective") {
|
|
553
|
+
node.stability = 5;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
diff.createNodes.push(node);
|
|
557
|
+
const nodeIndex = diff.createNodes.length - 1;
|
|
558
|
+
|
|
559
|
+
// Collect edges to existing nodes (need new node ID after creation)
|
|
560
|
+
if (Array.isArray(raw.edges_to_existing)) {
|
|
561
|
+
for (const edge of raw.edges_to_existing) {
|
|
562
|
+
if (!edge.target_node_id || !candidateNodeIds.has(edge.target_node_id))
|
|
563
|
+
continue;
|
|
564
|
+
if (!edge.relationship || !VALID_RELATIONSHIPS.has(edge.relationship))
|
|
565
|
+
continue;
|
|
566
|
+
deferredEdges.push({
|
|
567
|
+
newNodeIndex: nodeIndex,
|
|
568
|
+
targetNodeId: edge.target_node_id,
|
|
569
|
+
relationship: edge.relationship,
|
|
570
|
+
weight: clamp(Number(edge.weight) || 1.0, 0, 1),
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Collect triggers
|
|
576
|
+
if (Array.isArray(raw.triggers)) {
|
|
577
|
+
for (const t of raw.triggers) {
|
|
578
|
+
if (!t.type || !VALID_TRIGGER_TYPES.has(t.type)) continue;
|
|
579
|
+
deferredTriggers.push({
|
|
580
|
+
newNodeIndex: nodeIndex,
|
|
581
|
+
trigger: {
|
|
582
|
+
type: t.type as TriggerType,
|
|
583
|
+
schedule: t.schedule ?? null,
|
|
584
|
+
condition: t.condition ?? null,
|
|
585
|
+
conditionEmbedding: null, // Embedded async via job
|
|
586
|
+
threshold: t.type === "semantic" ? 0.7 : null,
|
|
587
|
+
eventDate: parseEpochMs(t.event_date),
|
|
588
|
+
rampDays: t.ramp_days ?? null,
|
|
589
|
+
followUpDays: t.follow_up_days ?? null,
|
|
590
|
+
recurring: t.recurring ?? false,
|
|
591
|
+
consumed: false,
|
|
592
|
+
cooldownMs: t.recurring ? 1000 * 60 * 60 * 12 : null, // 12h default cooldown
|
|
593
|
+
lastFired: null,
|
|
594
|
+
},
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Auto-create event trigger when event_date is set but LLM didn't include one,
|
|
600
|
+
// or replace a malformed event trigger (event_date unset) with a valid one.
|
|
601
|
+
if (
|
|
602
|
+
node.eventDate != null &&
|
|
603
|
+
(!Array.isArray(raw.triggers) ||
|
|
604
|
+
!raw.triggers.some(
|
|
605
|
+
(t) => t.type === "event" && t.event_date != null,
|
|
606
|
+
))
|
|
607
|
+
) {
|
|
608
|
+
// Remove any malformed event triggers (type=event but missing event_date)
|
|
609
|
+
const malformedIdx = deferredTriggers.findIndex(
|
|
610
|
+
(dt) =>
|
|
611
|
+
dt.newNodeIndex === nodeIndex &&
|
|
612
|
+
dt.trigger.type === "event" &&
|
|
613
|
+
dt.trigger.eventDate == null,
|
|
614
|
+
);
|
|
615
|
+
if (malformedIdx !== -1) {
|
|
616
|
+
deferredTriggers.splice(malformedIdx, 1);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
deferredTriggers.push({
|
|
620
|
+
newNodeIndex: nodeIndex,
|
|
621
|
+
trigger: {
|
|
622
|
+
type: "event" as TriggerType,
|
|
623
|
+
schedule: null,
|
|
624
|
+
condition: null,
|
|
625
|
+
conditionEmbedding: null,
|
|
626
|
+
threshold: null,
|
|
627
|
+
eventDate: node.eventDate,
|
|
628
|
+
rampDays: 7,
|
|
629
|
+
followUpDays: 2,
|
|
630
|
+
recurring: false,
|
|
631
|
+
consumed: false,
|
|
632
|
+
cooldownMs: null,
|
|
633
|
+
lastFired: null,
|
|
634
|
+
},
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Parse image refs
|
|
639
|
+
if (Array.isArray(raw.image_refs)) {
|
|
640
|
+
const validRefs: ImageRef[] = [];
|
|
641
|
+
for (const ref of raw.image_refs) {
|
|
642
|
+
if (!ref.message_id || typeof ref.message_id !== "string") continue;
|
|
643
|
+
if (typeof ref.block_index !== "number" || ref.block_index < 0)
|
|
644
|
+
continue;
|
|
645
|
+
if (!ref.description || typeof ref.description !== "string") continue;
|
|
646
|
+
const mimeType = resolveImageRefMimeType(
|
|
647
|
+
ref.message_id,
|
|
648
|
+
ref.block_index,
|
|
649
|
+
conversationId,
|
|
650
|
+
);
|
|
651
|
+
if (!mimeType) continue;
|
|
652
|
+
validRefs.push({
|
|
653
|
+
messageId: ref.message_id,
|
|
654
|
+
blockIndex: ref.block_index,
|
|
655
|
+
description: ref.description,
|
|
656
|
+
mimeType,
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
node.imageRefs = validRefs.length > 0 ? validRefs : null;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Parse updates
|
|
664
|
+
for (const raw of updateNodes) {
|
|
665
|
+
if (!raw.id || !candidateNodeIds.has(raw.id)) continue;
|
|
666
|
+
const changes: Record<string, unknown> = {};
|
|
667
|
+
if (raw.content) changes.content = raw.content;
|
|
668
|
+
if (raw.significance != null)
|
|
669
|
+
changes.significance = clamp(raw.significance, 0, 1);
|
|
670
|
+
if (raw.confidence != null)
|
|
671
|
+
changes.confidence = clamp(raw.confidence, 0, 1);
|
|
672
|
+
if (
|
|
673
|
+
raw.fidelity &&
|
|
674
|
+
["vivid", "clear", "faded", "gist"].includes(raw.fidelity)
|
|
675
|
+
)
|
|
676
|
+
changes.fidelity = raw.fidelity;
|
|
677
|
+
if (raw.event_date !== undefined) changes.eventDate = parseEpochMs(raw.event_date);
|
|
678
|
+
if (Object.keys(changes).length > 0) {
|
|
679
|
+
diff.updateNodes.push({ id: raw.id, changes });
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Parse edges between existing nodes
|
|
684
|
+
for (const raw of newEdges) {
|
|
685
|
+
if (!raw.source_node_id || !raw.target_node_id) continue;
|
|
686
|
+
if (
|
|
687
|
+
!candidateNodeIds.has(raw.source_node_id) ||
|
|
688
|
+
!candidateNodeIds.has(raw.target_node_id)
|
|
689
|
+
)
|
|
690
|
+
continue;
|
|
691
|
+
if (!raw.relationship || !VALID_RELATIONSHIPS.has(raw.relationship))
|
|
692
|
+
continue;
|
|
693
|
+
diff.createEdges.push({
|
|
694
|
+
sourceNodeId: raw.source_node_id,
|
|
695
|
+
targetNodeId: raw.target_node_id,
|
|
696
|
+
relationship: raw.relationship as NewEdge["relationship"],
|
|
697
|
+
weight: clamp(Number(raw.weight) || 1.0, 0, 1),
|
|
698
|
+
created: now,
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
return { diff, deferredEdges, deferredTriggers };
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// ---------------------------------------------------------------------------
|
|
706
|
+
// Main extraction pipeline
|
|
707
|
+
// ---------------------------------------------------------------------------
|
|
708
|
+
|
|
709
|
+
export interface ExtractionResult {
|
|
710
|
+
nodesCreated: number;
|
|
711
|
+
nodesUpdated: number;
|
|
712
|
+
nodesReinforced: number;
|
|
713
|
+
edgesCreated: number;
|
|
714
|
+
triggersCreated: number;
|
|
715
|
+
/** Epoch ms of the newest message included in extraction. Used for checkpointing. */
|
|
716
|
+
lastProcessedTimestamp?: number;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Run the full graph extraction pipeline for a completed conversation.
|
|
721
|
+
*
|
|
722
|
+
* 1. Load transcript from disk
|
|
723
|
+
* 2. Find candidate existing nodes via embedding search
|
|
724
|
+
* 3. LLM call → structured diff
|
|
725
|
+
* 4. Apply diff to graph store
|
|
726
|
+
* 5. Enqueue embedding jobs for new nodes and triggers
|
|
727
|
+
*/
|
|
728
|
+
export async function runGraphExtraction(
|
|
729
|
+
conversationId: string,
|
|
730
|
+
scopeId: string,
|
|
731
|
+
config: AssistantConfig,
|
|
732
|
+
opts?: {
|
|
733
|
+
/** Pre-loaded transcript text (skips disk read). Used by bootstrap. */
|
|
734
|
+
transcript?: string;
|
|
735
|
+
/** Additional node IDs that were in active context. */
|
|
736
|
+
activeContextNodeIds?: string[];
|
|
737
|
+
/**
|
|
738
|
+
* When set, only extract from messages after this checkpoint.
|
|
739
|
+
* Used for mid-conversation incremental extraction (batch mode).
|
|
740
|
+
* The checkpoint is the message timestamp of the last extracted message.
|
|
741
|
+
*/
|
|
742
|
+
afterTimestamp?: number;
|
|
743
|
+
/** Override the conversation timestamp (epoch ms). Used by bootstrap. */
|
|
744
|
+
conversationTimestamp?: number;
|
|
745
|
+
/** Skip Qdrant search for candidates (use DB query instead). Used by bootstrap
|
|
746
|
+
* when embedding jobs haven't been processed yet. */
|
|
747
|
+
skipQdrant?: boolean;
|
|
748
|
+
/** Embed nodes synchronously instead of enqueuing jobs. Used by bootstrap
|
|
749
|
+
* so nodes are searchable immediately without the jobs worker running. */
|
|
750
|
+
embedInline?: boolean;
|
|
751
|
+
},
|
|
752
|
+
): Promise<ExtractionResult> {
|
|
753
|
+
const emptyResult: ExtractionResult = {
|
|
754
|
+
nodesCreated: 0,
|
|
755
|
+
nodesUpdated: 0,
|
|
756
|
+
nodesReinforced: 0,
|
|
757
|
+
edgesCreated: 0,
|
|
758
|
+
triggersCreated: 0,
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
// 1. Load transcript — try multimodal first, fall back to text-only
|
|
762
|
+
const imageResult = loadTranscriptWithImages(
|
|
763
|
+
conversationId,
|
|
764
|
+
opts?.afterTimestamp,
|
|
765
|
+
);
|
|
766
|
+
|
|
767
|
+
let transcript = opts?.transcript;
|
|
768
|
+
if (!transcript) {
|
|
769
|
+
transcript =
|
|
770
|
+
loadTranscriptFromDisk(conversationId, opts?.afterTimestamp) ?? undefined;
|
|
771
|
+
if (!transcript) {
|
|
772
|
+
// If we have a multimodal result but no disk transcript, extract text
|
|
773
|
+
// from the multimodal message content blocks for candidate search.
|
|
774
|
+
if (imageResult) {
|
|
775
|
+
transcript = imageResult.message.content
|
|
776
|
+
.filter(
|
|
777
|
+
(b): b is { type: "text"; text: string } => b.type === "text",
|
|
778
|
+
)
|
|
779
|
+
.map((b) => b.text)
|
|
780
|
+
.join("\n");
|
|
781
|
+
}
|
|
782
|
+
if (!transcript) {
|
|
783
|
+
log.warn(
|
|
784
|
+
{ conversationId },
|
|
785
|
+
"No transcript found on disk, skipping extraction",
|
|
786
|
+
);
|
|
787
|
+
return emptyResult;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Skip very short conversations (< 100 chars)
|
|
793
|
+
if (transcript.trim().length < 100) {
|
|
794
|
+
return emptyResult;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// 2. Get provider
|
|
798
|
+
const provider = await getConfiguredProvider();
|
|
799
|
+
if (!provider) {
|
|
800
|
+
throw new BackendUnavailableError(
|
|
801
|
+
"Provider unavailable for graph extraction",
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// 3. Find candidate existing nodes
|
|
806
|
+
const candidateNodes = await findCandidateNodes(
|
|
807
|
+
transcript,
|
|
808
|
+
scopeId,
|
|
809
|
+
config,
|
|
810
|
+
opts?.activeContextNodeIds,
|
|
811
|
+
opts?.skipQdrant,
|
|
812
|
+
);
|
|
813
|
+
const candidateNodeIds = new Set(candidateNodes.map((n) => n.id));
|
|
814
|
+
|
|
815
|
+
// 4. Build prompt
|
|
816
|
+
const userPersona = resolveGuardianPersona();
|
|
817
|
+
const identityContext = buildCoreIdentityContext({
|
|
818
|
+
userPersona: userPersona ?? undefined,
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
const systemPrompt = buildGraphExtractionSystemPrompt(
|
|
822
|
+
candidateNodes.map((n) => ({ id: n.id, type: n.type, content: n.content })),
|
|
823
|
+
identityContext,
|
|
824
|
+
);
|
|
825
|
+
|
|
826
|
+
// 5. Resolve conversation timestamp before the LLM call so we can include
|
|
827
|
+
// the date in the prompt — without it the model can't resolve "today"
|
|
828
|
+
// or correctly date events mentioned in the conversation.
|
|
829
|
+
const conversationTimestamp =
|
|
830
|
+
opts?.conversationTimestamp ??
|
|
831
|
+
resolveConversationTimestamp(conversationId) ??
|
|
832
|
+
imageResult?.lastTimestamp ??
|
|
833
|
+
Date.now();
|
|
834
|
+
|
|
835
|
+
const convDate = new Date(conversationTimestamp);
|
|
836
|
+
const conversationDate =
|
|
837
|
+
convDate.toLocaleDateString("en-US", {
|
|
838
|
+
weekday: "long",
|
|
839
|
+
year: "numeric",
|
|
840
|
+
month: "long",
|
|
841
|
+
day: "numeric",
|
|
842
|
+
}) +
|
|
843
|
+
" at " +
|
|
844
|
+
convDate.toLocaleTimeString("en-US", {
|
|
845
|
+
hour: "numeric",
|
|
846
|
+
minute: "2-digit",
|
|
847
|
+
hour12: true,
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
// 6. LLM call — use multimodal message when images are present
|
|
851
|
+
const useMultimodal = imageResult?.hasImages === true;
|
|
852
|
+
|
|
853
|
+
const extractionMessages: Message[] = useMultimodal
|
|
854
|
+
? [
|
|
855
|
+
{
|
|
856
|
+
role: "user",
|
|
857
|
+
content: [
|
|
858
|
+
{
|
|
859
|
+
type: "text" as const,
|
|
860
|
+
text: `## Conversation Date\n\n${conversationDate}\n\n## Conversation Transcript\n\n`,
|
|
861
|
+
},
|
|
862
|
+
...imageResult.message.content,
|
|
863
|
+
],
|
|
864
|
+
},
|
|
865
|
+
]
|
|
866
|
+
: [
|
|
867
|
+
userMessage(
|
|
868
|
+
`## Conversation Date\n\n${conversationDate}\n\n## Conversation Transcript\n\n${transcript}`,
|
|
869
|
+
),
|
|
870
|
+
];
|
|
871
|
+
|
|
872
|
+
const response = await provider.sendMessage(
|
|
873
|
+
extractionMessages,
|
|
874
|
+
[EXTRACT_TOOL_SCHEMA],
|
|
875
|
+
systemPrompt,
|
|
876
|
+
{
|
|
877
|
+
config: {
|
|
878
|
+
modelIntent: "quality-optimized" as const,
|
|
879
|
+
tool_choice: { type: "tool" as const, name: "extract_graph_diff" },
|
|
880
|
+
},
|
|
881
|
+
},
|
|
882
|
+
);
|
|
883
|
+
|
|
884
|
+
const toolBlock = extractToolUse(response);
|
|
885
|
+
if (!toolBlock) {
|
|
886
|
+
log.warn({ conversationId }, "No tool_use block in extraction response");
|
|
887
|
+
return emptyResult;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const { diff, deferredEdges, deferredTriggers } = parseExtractionResponse(
|
|
891
|
+
toolBlock.input as Record<string, unknown>,
|
|
892
|
+
conversationId,
|
|
893
|
+
scopeId,
|
|
894
|
+
candidateNodeIds,
|
|
895
|
+
conversationTimestamp,
|
|
896
|
+
);
|
|
897
|
+
|
|
898
|
+
// 7. Handle supersession (inherit durability before applying diff)
|
|
899
|
+
for (const edge of diff.createEdges) {
|
|
900
|
+
if (edge.relationship === "supersedes") {
|
|
901
|
+
// Supersession is handled differently — see supersedeNode in store
|
|
902
|
+
// For now, just mark it; full supersession is applied after node creation
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// 8. Apply the diff
|
|
907
|
+
const result = applyDiff(diff);
|
|
908
|
+
|
|
909
|
+
// 9. Apply deferred edges and triggers using the created node IDs
|
|
910
|
+
const createdNodeIds = result.createdNodeIds;
|
|
911
|
+
let edgesCreated = result.edgesCreated;
|
|
912
|
+
let triggersCreated = result.triggersCreated;
|
|
913
|
+
|
|
914
|
+
for (const de of deferredEdges) {
|
|
915
|
+
const newNodeId = createdNodeIds[de.newNodeIndex];
|
|
916
|
+
if (!newNodeId) continue;
|
|
917
|
+
|
|
918
|
+
createEdge({
|
|
919
|
+
sourceNodeId: newNodeId,
|
|
920
|
+
targetNodeId: de.targetNodeId,
|
|
921
|
+
relationship: de.relationship as NewEdge["relationship"],
|
|
922
|
+
weight: de.weight,
|
|
923
|
+
created: conversationTimestamp,
|
|
924
|
+
});
|
|
925
|
+
edgesCreated++;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const { createTrigger } = await import("./store.js");
|
|
929
|
+
|
|
930
|
+
for (const dt of deferredTriggers) {
|
|
931
|
+
const newNodeId = createdNodeIds[dt.newNodeIndex];
|
|
932
|
+
if (!newNodeId) continue;
|
|
933
|
+
|
|
934
|
+
const trigger = createTrigger({
|
|
935
|
+
...dt.trigger,
|
|
936
|
+
nodeId: newNodeId,
|
|
937
|
+
});
|
|
938
|
+
triggersCreated++;
|
|
939
|
+
|
|
940
|
+
if (trigger.type === "semantic" && trigger.condition) {
|
|
941
|
+
enqueueGraphTriggerEmbed(trigger.id);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// 10. Embed new nodes — inline for bootstrap, async for live conversations
|
|
946
|
+
const createdNodes = getNodesByIds(createdNodeIds);
|
|
947
|
+
if (opts?.embedInline) {
|
|
948
|
+
const { embedGraphNodeDirect } = await import("./graph-search.js");
|
|
949
|
+
for (const node of createdNodes) {
|
|
950
|
+
try {
|
|
951
|
+
await embedGraphNodeDirect(node, config);
|
|
952
|
+
} catch (err) {
|
|
953
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
954
|
+
log.warn(
|
|
955
|
+
{ nodeId: node.id, err: msg },
|
|
956
|
+
"Inline embed failed (non-fatal)",
|
|
957
|
+
);
|
|
958
|
+
console.error(` [embed] Failed for ${node.id}: ${msg}`);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
} else {
|
|
962
|
+
for (const node of createdNodes) {
|
|
963
|
+
enqueueGraphNodeEmbed(node.id);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
log.info(
|
|
968
|
+
{
|
|
969
|
+
conversationId,
|
|
970
|
+
nodesCreated: result.nodesCreated,
|
|
971
|
+
nodesUpdated: result.nodesUpdated,
|
|
972
|
+
nodesReinforced: result.nodesReinforced,
|
|
973
|
+
edgesCreated,
|
|
974
|
+
triggersCreated,
|
|
975
|
+
},
|
|
976
|
+
"Graph extraction complete",
|
|
977
|
+
);
|
|
978
|
+
|
|
979
|
+
return {
|
|
980
|
+
nodesCreated: result.nodesCreated,
|
|
981
|
+
nodesUpdated: result.nodesUpdated,
|
|
982
|
+
nodesReinforced: result.nodesReinforced,
|
|
983
|
+
edgesCreated,
|
|
984
|
+
triggersCreated,
|
|
985
|
+
lastProcessedTimestamp: conversationTimestamp,
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// ---------------------------------------------------------------------------
|
|
990
|
+
// Helpers
|
|
991
|
+
// ---------------------------------------------------------------------------
|
|
992
|
+
|
|
993
|
+
function resolveConversationTimestamp(conversationId: string): number | null {
|
|
994
|
+
const db = getDb();
|
|
995
|
+
// Use the last message timestamp, not the conversation creation time.
|
|
996
|
+
// A conversation can span hours/days — memories should be timestamped
|
|
997
|
+
// to when the relevant content was actually discussed.
|
|
998
|
+
const lastMsg = db
|
|
999
|
+
.select({ createdAt: messages.createdAt })
|
|
1000
|
+
.from(messages)
|
|
1001
|
+
.where(eq(messages.conversationId, conversationId))
|
|
1002
|
+
.orderBy(desc(messages.createdAt))
|
|
1003
|
+
.limit(1)
|
|
1004
|
+
.get();
|
|
1005
|
+
if (lastMsg) return lastMsg.createdAt;
|
|
1006
|
+
|
|
1007
|
+
// Fallback to conversation creation time if no messages in DB
|
|
1008
|
+
const conv = db
|
|
1009
|
+
.select({ createdAt: conversations.createdAt })
|
|
1010
|
+
.from(conversations)
|
|
1011
|
+
.where(eq(conversations.id, conversationId))
|
|
1012
|
+
.get();
|
|
1013
|
+
return conv?.createdAt ?? null;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function resolveImageRefMimeType(
|
|
1017
|
+
messageId: string,
|
|
1018
|
+
blockIndex: number,
|
|
1019
|
+
conversationId: string,
|
|
1020
|
+
): string | null {
|
|
1021
|
+
const db = getDb();
|
|
1022
|
+
const msg = db
|
|
1023
|
+
.select({ content: messages.content })
|
|
1024
|
+
.from(messages)
|
|
1025
|
+
.where(
|
|
1026
|
+
and(
|
|
1027
|
+
eq(messages.id, messageId),
|
|
1028
|
+
eq(messages.conversationId, conversationId),
|
|
1029
|
+
),
|
|
1030
|
+
)
|
|
1031
|
+
.get();
|
|
1032
|
+
if (!msg) return null;
|
|
1033
|
+
|
|
1034
|
+
try {
|
|
1035
|
+
const blocks = JSON.parse(msg.content) as Array<{
|
|
1036
|
+
type?: string;
|
|
1037
|
+
source?: { media_type?: string };
|
|
1038
|
+
}>;
|
|
1039
|
+
const block = blocks[blockIndex];
|
|
1040
|
+
if (!block || block.type !== "image") return null;
|
|
1041
|
+
return block.source?.media_type ?? null;
|
|
1042
|
+
} catch {
|
|
1043
|
+
return null;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function loadTranscriptFromDisk(
|
|
1048
|
+
conversationId: string,
|
|
1049
|
+
afterTimestamp?: number,
|
|
1050
|
+
): string | null {
|
|
1051
|
+
const db = getDb();
|
|
1052
|
+
const conv = db
|
|
1053
|
+
.select({ createdAt: conversations.createdAt })
|
|
1054
|
+
.from(conversations)
|
|
1055
|
+
.where(eq(conversations.id, conversationId))
|
|
1056
|
+
.get();
|
|
1057
|
+
|
|
1058
|
+
if (!conv) return null;
|
|
1059
|
+
|
|
1060
|
+
try {
|
|
1061
|
+
const dirPath = getConversationDirPath(conversationId, conv.createdAt);
|
|
1062
|
+
const messagesPath = join(dirPath, "messages.jsonl");
|
|
1063
|
+
const content = readFileSync(messagesPath, "utf-8");
|
|
1064
|
+
|
|
1065
|
+
const lines = content
|
|
1066
|
+
.trim()
|
|
1067
|
+
.split("\n")
|
|
1068
|
+
.filter((line) => line.length > 0);
|
|
1069
|
+
|
|
1070
|
+
const parts: string[] = [];
|
|
1071
|
+
for (const line of lines) {
|
|
1072
|
+
try {
|
|
1073
|
+
const msg = JSON.parse(line) as {
|
|
1074
|
+
role?: string;
|
|
1075
|
+
content?: string;
|
|
1076
|
+
ts?: string;
|
|
1077
|
+
};
|
|
1078
|
+
if (!msg.role || !msg.content) continue;
|
|
1079
|
+
|
|
1080
|
+
// Filter by timestamp for incremental extraction
|
|
1081
|
+
if (afterTimestamp && msg.ts) {
|
|
1082
|
+
const msgTime = new Date(msg.ts).getTime();
|
|
1083
|
+
if (msgTime <= afterTimestamp) continue;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
parts.push(`[${msg.role}]: ${msg.content}`);
|
|
1087
|
+
} catch {
|
|
1088
|
+
// Skip malformed lines
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
return parts.length > 0 ? parts.join("\n\n") : null;
|
|
1093
|
+
} catch {
|
|
1094
|
+
return null;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* Load a conversation transcript from the DB with interleaved text and image
|
|
1100
|
+
* content blocks. Returns a single consolidated `Message` with role "user"
|
|
1101
|
+
* containing text annotations and `ImageContent` blocks so the extraction LLM
|
|
1102
|
+
* can see images alongside their textual context.
|
|
1103
|
+
*
|
|
1104
|
+
* Images are capped at 10 per transcript to control extraction cost.
|
|
1105
|
+
*/
|
|
1106
|
+
export function loadTranscriptWithImages(
|
|
1107
|
+
conversationId: string,
|
|
1108
|
+
afterTimestamp?: number,
|
|
1109
|
+
): {
|
|
1110
|
+
message: Message;
|
|
1111
|
+
hasImages: boolean;
|
|
1112
|
+
lastTimestamp: number | null;
|
|
1113
|
+
} | null {
|
|
1114
|
+
const db = getDb();
|
|
1115
|
+
|
|
1116
|
+
// Build query conditions
|
|
1117
|
+
const conditions = [eq(messages.conversationId, conversationId)];
|
|
1118
|
+
if (afterTimestamp !== undefined) {
|
|
1119
|
+
conditions.push(gt(messages.createdAt, afterTimestamp));
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
const rows = db
|
|
1123
|
+
.select({
|
|
1124
|
+
id: messages.id,
|
|
1125
|
+
role: messages.role,
|
|
1126
|
+
content: messages.content,
|
|
1127
|
+
createdAt: messages.createdAt,
|
|
1128
|
+
})
|
|
1129
|
+
.from(messages)
|
|
1130
|
+
.where(and(...conditions))
|
|
1131
|
+
.orderBy(asc(messages.createdAt))
|
|
1132
|
+
.all();
|
|
1133
|
+
|
|
1134
|
+
if (rows.length === 0) return null;
|
|
1135
|
+
|
|
1136
|
+
const MAX_IMAGES = 10;
|
|
1137
|
+
let imageCount = 0;
|
|
1138
|
+
let hasImagesFlag = false;
|
|
1139
|
+
let totalTextLength = 0;
|
|
1140
|
+
let lastTimestamp: number | null = null;
|
|
1141
|
+
|
|
1142
|
+
const contentBlocks: ContentBlock[] = [];
|
|
1143
|
+
|
|
1144
|
+
for (const row of rows) {
|
|
1145
|
+
lastTimestamp = row.createdAt;
|
|
1146
|
+
|
|
1147
|
+
let parsed: ContentBlock[];
|
|
1148
|
+
try {
|
|
1149
|
+
const raw = JSON.parse(row.content) as unknown;
|
|
1150
|
+
if (typeof raw === "string") {
|
|
1151
|
+
parsed = [{ type: "text", text: raw }];
|
|
1152
|
+
} else if (Array.isArray(raw)) {
|
|
1153
|
+
parsed = raw as ContentBlock[];
|
|
1154
|
+
} else {
|
|
1155
|
+
continue;
|
|
1156
|
+
}
|
|
1157
|
+
} catch {
|
|
1158
|
+
// If content is a plain string (not JSON), wrap it
|
|
1159
|
+
parsed = [{ type: "text", text: row.content }];
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// Build content blocks preserving original text/image interleaving
|
|
1163
|
+
let prefixAdded = false;
|
|
1164
|
+
for (let i = 0; i < parsed.length; i++) {
|
|
1165
|
+
const block = parsed[i];
|
|
1166
|
+
if (block?.type === "text") {
|
|
1167
|
+
const rawText =
|
|
1168
|
+
typeof block.text === "string" ? block.text : "";
|
|
1169
|
+
const text = prefixAdded
|
|
1170
|
+
? rawText
|
|
1171
|
+
: `[${row.role}]: ${rawText}`;
|
|
1172
|
+
prefixAdded = true;
|
|
1173
|
+
totalTextLength += text.length;
|
|
1174
|
+
contentBlocks.push({ type: "text", text });
|
|
1175
|
+
} else if (block?.type === "image") {
|
|
1176
|
+
if (imageCount < MAX_IMAGES) {
|
|
1177
|
+
const imgBlock = block as ImageContent;
|
|
1178
|
+
// Add annotation so the extraction LLM knows the image's reference coordinates
|
|
1179
|
+
contentBlocks.push({
|
|
1180
|
+
type: "text",
|
|
1181
|
+
text: `<image message_id="${row.id}" block_index="${i}" type="${imgBlock.source.media_type}" />`,
|
|
1182
|
+
});
|
|
1183
|
+
contentBlocks.push(imgBlock);
|
|
1184
|
+
imageCount++;
|
|
1185
|
+
hasImagesFlag = true;
|
|
1186
|
+
}
|
|
1187
|
+
// After cap, skip image blocks but continue processing text
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// Skip if transcript is too short (images count toward the threshold)
|
|
1193
|
+
if (totalTextLength < 100 && !hasImagesFlag) return null;
|
|
1194
|
+
|
|
1195
|
+
const message: Message = {
|
|
1196
|
+
role: "user",
|
|
1197
|
+
content: contentBlocks,
|
|
1198
|
+
};
|
|
1199
|
+
|
|
1200
|
+
return { message, hasImages: hasImagesFlag, lastTimestamp };
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
async function findCandidateNodes(
|
|
1204
|
+
transcript: string,
|
|
1205
|
+
scopeId: string,
|
|
1206
|
+
config: AssistantConfig,
|
|
1207
|
+
activeContextNodeIds?: string[],
|
|
1208
|
+
skipQdrant?: boolean,
|
|
1209
|
+
) {
|
|
1210
|
+
const allNodeIds = new Set<string>();
|
|
1211
|
+
|
|
1212
|
+
if (skipQdrant) {
|
|
1213
|
+
// Bootstrap mode: load candidates directly from DB (embeddings may not be ready).
|
|
1214
|
+
// Get the most recent and most significant non-gone nodes.
|
|
1215
|
+
const dbCandidates = queryNodes({
|
|
1216
|
+
scopeId,
|
|
1217
|
+
fidelityNot: ["gone"],
|
|
1218
|
+
limit: 100,
|
|
1219
|
+
});
|
|
1220
|
+
for (const node of dbCandidates) allNodeIds.add(node.id);
|
|
1221
|
+
} else {
|
|
1222
|
+
// Live mode: semantic search via Qdrant
|
|
1223
|
+
const { embedWithRetry } = await import("../embed.js");
|
|
1224
|
+
const searchText =
|
|
1225
|
+
transcript.length > 3000
|
|
1226
|
+
? transcript.slice(0, 1500) + "\n...\n" + transcript.slice(-1500)
|
|
1227
|
+
: transcript;
|
|
1228
|
+
|
|
1229
|
+
try {
|
|
1230
|
+
const embedding = await embedWithRetry(config, [searchText]);
|
|
1231
|
+
const queryVector = embedding.vectors[0];
|
|
1232
|
+
if (queryVector) {
|
|
1233
|
+
const searchResults = await searchGraphNodes(queryVector, 100, [
|
|
1234
|
+
scopeId,
|
|
1235
|
+
]);
|
|
1236
|
+
for (const r of searchResults) allNodeIds.add(r.nodeId);
|
|
1237
|
+
}
|
|
1238
|
+
} catch (err) {
|
|
1239
|
+
log.warn(
|
|
1240
|
+
{ err },
|
|
1241
|
+
"Failed to embed transcript for candidate search, continuing without candidates",
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// Combine with active context nodes
|
|
1247
|
+
if (activeContextNodeIds) {
|
|
1248
|
+
for (const id of activeContextNodeIds) allNodeIds.add(id);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
if (allNodeIds.size === 0) return [];
|
|
1252
|
+
|
|
1253
|
+
return getNodesByIds([...allNodeIds]);
|
|
1254
|
+
}
|