From a3ebdfa80c59be6d44515b5d76f3c752e1998d65 Mon Sep 17 00:00:00 2001 From: Ihab Khaled Date: Sun, 24 May 2026 05:33:48 +0300 Subject: [PATCH 1/2] feat(memory,context,chat): ship memory + context v2 flagship MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trust + safety + observability layer over the existing memory + context-pack surface. 18 planning phase docs informed the design (kept locally in .claude/Integrations/memory-context-v2/, gitignored). memory v2 (ADR-033 + ADR-034): - suggestion-gated AI extraction: MESSAGE_COMPLETED handler writes MemorySuggestion rows; auto-approve fires only for confidence >= threshold AND sensitivity == NORMAL. - sensitivity classification at write time: regex pre-filter (AWS, JWT, SSN, CC, private-key, Google, GitHub, OpenAI) redacts content before persistence. - scopes (USER/THREAD/WORKSPACE/PROJECT) enforced at the query layer. - retention + pause + pinned + qualityScore + useCount + lastUsedAt + provenanceJson. - new tables: memory_suggestions, memory_usages, memory_audit_logs, memory_preferences. - new modules: memory-suggestions, memory-preferences, memory-audit, memory-usage. - audit row outlives memory deletion (memoryId nullable) for RTBF compliance. context v2 (ADR-035 + ADR-036): - scope enum + scopeRef; legacy free-text scope preserved as legacy_scope. - item type enum (TEXT/FILE/URL/MARKDOWN/SNIPPET/MEMORY_REF) + legacy_type. - visibility (PRIVATE/WORKSPACE/PUBLIC); per-pack and per-item enablement and pinning. - new tables: context_pack_versions (immutable, pruned at 20), context_pack_usages, context_pack_attachments, context_pack_templates. - repository auto-increments version on every mutation. memory + context integration v2 (ADR-037 + ADR-038): - POST /internal/memories/retrieve returns a typed RetrievalBundle — single source of truth for chat assembly, inspector, and receipts. - ChatThread gains useMemory / useContext toggles; ChatMessageContextReceipt table persists the bundle; GET /chat-messages/:id/context-receipt enforces per-user ownership and sanitizes REDACTED content. - 19 new events (memory.*, context_pack.*, context.receipt_written, chat_thread.*_toggled). frontend: - /memory rebuilt as 3-tab page (Saved / Suggestions / Audit) with scope, source, sensitivity, search filters. - suggestion approve / reject / suppress-similar UI; audit timeline; controller hook orchestrates tabs + filters. - 8 locale files + i18n.types.ts updated atomically with 29 new keys (real translations per locale). infra: - .env.example, scripts/install.{sh,ps1} include the 14 new V2 env vars. - two non-destructive Prisma migrations (additive columns + new tables + safe enum coercion of legacy free-text columns). validation: - root typecheck across all 14 workspaces clean. - root lint clean (0 errors). - backend tests: 593 passed across all workspaces. - frontend tests: 512 passed. - build: all workspaces succeed. deferred to follow-up sessions (documented in planning docs): - sensitivity classifier Ollama call (regex pre-filter ships; Ollama fallback enqueued). - memory + pack embedding manager and cosine ranking (schema is ready; embedding pipeline lands in next session). - compose-time preview popover UI and receipt-button placement in chat (the read endpoint is in place). - context-pack version revert UI / template gallery / NDJSON import-export (Phase 3 slice). Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 23 ++ CLAUDE.md | 53 ++- .../migration.sql | 23 ++ apps/claw-chat-service/prisma/schema.prisma | 16 + apps/claw-chat-service/src/app/app.module.ts | 2 + .../utilities/context-receipt-json.utility.ts | 15 + .../context-receipts.module.ts | 11 + .../controllers/context-receipt.controller.ts | 18 + .../context-receipt.repository.ts | 36 ++ .../services/context-receipt.service.ts | 72 ++++ .../types/context-receipt.types.ts | 8 + .../src/app/(portal)/memory/page.tsx | 185 +++++++-- .../src/components/memory/audit-list.tsx | 51 +++ .../components/memory/suggestions-list.tsx | 97 +++++ .../enums/context-pack-item-type-v2.enum.ts | 11 + .../src/enums/context-pack-scope.enum.ts | 6 + .../src/enums/context-pack-visibility.enum.ts | 5 + apps/claw-frontend/src/enums/index.ts | 10 + .../src/enums/memory-audit-action.enum.ts | 14 + .../src/enums/memory-retention.enum.ts | 5 + .../src/enums/memory-scope.enum.ts | 6 + .../src/enums/memory-sensitivity.enum.ts | 5 + .../src/enums/memory-source.enum.ts | 6 + .../enums/memory-suggestion-status.enum.ts | 8 + .../src/enums/memory-tab.enum.ts | 5 + .../memory/use-approve-memory-suggestion.ts | 15 + .../src/hooks/memory/use-delete-memory.ts | 15 +- .../src/hooks/memory/use-memories.ts | 37 +- .../src/hooks/memory/use-memory-audit.ts | 32 ++ .../src/hooks/memory/use-memory-page.ts | 83 +++- .../hooks/memory/use-memory-preferences.ts | 28 ++ .../hooks/memory/use-memory-suggestions.ts | 25 ++ .../memory/use-reject-memory-suggestion.ts | 15 + apps/claw-frontend/src/lib/i18n/locales/ar.ts | 28 ++ apps/claw-frontend/src/lib/i18n/locales/de.ts | 31 +- apps/claw-frontend/src/lib/i18n/locales/en.ts | 28 ++ apps/claw-frontend/src/lib/i18n/locales/es.ts | 31 +- apps/claw-frontend/src/lib/i18n/locales/fr.ts | 41 +- apps/claw-frontend/src/lib/i18n/locales/hi.ts | 31 +- apps/claw-frontend/src/lib/i18n/locales/it.ts | 33 +- apps/claw-frontend/src/lib/i18n/locales/pt.ts | 31 +- apps/claw-frontend/src/lib/i18n/locales/ru.ts | 31 +- .../repositories/memory/memory.repository.ts | 121 +++++- .../src/repositories/shared/query-keys.ts | 7 + .../src/types/component.types.ts | 23 +- apps/claw-frontend/src/types/i18n.types.ts | 29 ++ apps/claw-frontend/src/types/index.ts | 15 +- apps/claw-frontend/src/types/memory.types.ts | 128 +++++- apps/claw-memory-service/CLAUDE.md | 36 +- .../migration.sql | 220 +++++++++++ apps/claw-memory-service/prisma/schema.prisma | 283 +++++++++++++- .../src/common/constants/index.ts | 17 +- .../constants/memory-retrieval.constants.ts | 5 + .../constants/memory-sensitivity.constants.ts | 38 ++ .../enums/context-pack-item-type.enum.ts | 1 + .../common/enums/context-pack-scope.enum.ts | 1 + .../enums/context-pack-visibility.enum.ts | 1 + .../src/common/enums/index.ts | 14 +- .../common/enums/memory-audit-action.enum.ts | 1 + .../src/common/enums/memory-retention.enum.ts | 1 + .../src/common/enums/memory-scope.enum.ts | 1 + .../common/enums/memory-sensitivity.enum.ts | 1 + .../src/common/enums/memory-source.enum.ts | 1 + .../enums/memory-suggestion-status.enum.ts | 1 + .../src/common/enums/retrieval-reason.enum.ts | 1 + .../common/utilities/date-coerce.utility.ts | 9 + .../__tests__/context-packs.service.spec.ts | 359 +++++------------ .../dto/add-context-pack-item.dto.ts | 15 +- .../dto/create-context-pack.dto.ts | 18 +- .../dto/update-context-pack.dto.ts | 17 +- .../context-packs.repository.spec.ts | 4 +- .../repositories/context-packs.repository.ts | 114 +++++- .../services/context-packs.service.ts | 70 ++-- .../types/context-packs.types.ts | 54 ++- .../controllers/memory-audit.controller.ts | 27 ++ .../memory-audit-log.repository.ts | 40 ++ .../services/memory-audit.service.ts | 38 ++ .../memory-audit/types/memory-audit.types.ts | 9 + .../constants/memory-preference.constants.ts | 10 + .../memory-preferences.controller.ts | 29 ++ .../dto/upsert-memory-preference.dto.ts | 12 + .../memory-preference.repository.ts | 42 ++ .../services/memory-preference.service.ts | 30 ++ .../types/memory-preference.types.ts | 17 + .../memory-suggestions.controller.ts | 64 +++ .../dto/approve-suggestion.dto.ts | 12 + .../dto/bulk-approve-suggestions.dto.ts | 7 + .../dto/list-memory-suggestions-query.dto.ts | 10 + .../dto/reject-suggestion.dto.ts | 8 + .../memory-suggestion.repository.ts | 89 +++++ .../services/memory-suggestion.service.ts | 226 +++++++++++ .../types/memory-suggestion.types.ts | 32 ++ .../controllers/memory-usage.controller.ts | 28 ++ .../repositories/memory-usage.repository.ts | 40 ++ .../services/memory-usage.service.ts | 31 ++ .../memory-usage/types/memory-usage.types.ts | 8 + .../memory/__tests__/memory.service.spec.ts | 366 ++++++------------ .../__tests__/memory.controller.spec.ts | 6 +- .../memory-retrieval.controller.ts | 32 ++ .../memory/controllers/memory.controller.ts | 64 ++- .../modules/memory/dto/create-memory.dto.ts | 32 +- .../memory/dto/list-memories-query.dto.ts | 36 +- .../src/modules/memory/dto/retrieve.dto.ts | 34 ++ .../modules/memory/dto/search-memories.dto.ts | 12 + .../modules/memory/dto/update-memory.dto.ts | 15 +- .../managers/memory-sensitivity.manager.ts | 57 +++ .../src/modules/memory/memory.module.ts | 55 ++- .../__tests__/memory.repository.spec.ts | 10 +- .../memory/repositories/memory.repository.ts | 147 +++++-- .../services/memory-retrieval.service.ts | 198 ++++++++++ .../modules/memory/services/memory.service.ts | 310 +++++++++++---- .../memory/types/memory-sensitivity.types.ts | 8 + .../src/modules/memory/types/memory.types.ts | 43 +- .../memory-context-integration.md | 123 ++++++ docs/13-adr/033-memory-suggestion-queue.md | 42 ++ .../034-memory-scopes-and-sensitivity.md | 48 +++ docs/13-adr/035-context-pack-versioning.md | 24 ++ .../036-context-pack-scoping-and-sharing.md | 28 ++ docs/13-adr/037-unified-retrieval-bundle.md | 26 ++ docs/13-adr/038-context-receipt-store.md | 24 ++ .../src/enums/context-pack-item-type.enum.ts | 8 + .../src/enums/context-pack-scope.enum.ts | 6 + .../src/enums/context-pack-visibility.enum.ts | 5 + packages/shared-types/src/enums/index.ts | 13 + .../src/enums/memory-audit-action.enum.ts | 14 + .../src/enums/memory-retention.enum.ts | 5 + .../src/enums/memory-scope.enum.ts | 6 + .../src/enums/memory-sensitivity.enum.ts | 5 + .../src/enums/memory-source.enum.ts | 6 + .../enums/memory-suggestion-status.enum.ts | 8 + .../src/enums/retrieval-reason.enum.ts | 10 + .../shared-types/src/events/event-patterns.ts | 22 ++ .../src/events/event-payloads.type.ts | 167 +++++++- packages/shared-types/src/types/index.ts | 7 + .../shared-types/src/types/retrieval.type.ts | 63 +++ scripts/install.ps1 | 19 + scripts/install.sh | 19 + 137 files changed, 4915 insertions(+), 849 deletions(-) create mode 100644 apps/claw-chat-service/prisma/migrations/20260524000000_chat_context_v2/migration.sql create mode 100644 apps/claw-chat-service/src/common/utilities/context-receipt-json.utility.ts create mode 100644 apps/claw-chat-service/src/modules/context-receipts/context-receipts.module.ts create mode 100644 apps/claw-chat-service/src/modules/context-receipts/controllers/context-receipt.controller.ts create mode 100644 apps/claw-chat-service/src/modules/context-receipts/repositories/context-receipt.repository.ts create mode 100644 apps/claw-chat-service/src/modules/context-receipts/services/context-receipt.service.ts create mode 100644 apps/claw-chat-service/src/modules/context-receipts/types/context-receipt.types.ts create mode 100644 apps/claw-frontend/src/components/memory/audit-list.tsx create mode 100644 apps/claw-frontend/src/components/memory/suggestions-list.tsx create mode 100644 apps/claw-frontend/src/enums/context-pack-item-type-v2.enum.ts create mode 100644 apps/claw-frontend/src/enums/context-pack-scope.enum.ts create mode 100644 apps/claw-frontend/src/enums/context-pack-visibility.enum.ts create mode 100644 apps/claw-frontend/src/enums/memory-audit-action.enum.ts create mode 100644 apps/claw-frontend/src/enums/memory-retention.enum.ts create mode 100644 apps/claw-frontend/src/enums/memory-scope.enum.ts create mode 100644 apps/claw-frontend/src/enums/memory-sensitivity.enum.ts create mode 100644 apps/claw-frontend/src/enums/memory-source.enum.ts create mode 100644 apps/claw-frontend/src/enums/memory-suggestion-status.enum.ts create mode 100644 apps/claw-frontend/src/enums/memory-tab.enum.ts create mode 100644 apps/claw-frontend/src/hooks/memory/use-approve-memory-suggestion.ts create mode 100644 apps/claw-frontend/src/hooks/memory/use-memory-audit.ts create mode 100644 apps/claw-frontend/src/hooks/memory/use-memory-preferences.ts create mode 100644 apps/claw-frontend/src/hooks/memory/use-memory-suggestions.ts create mode 100644 apps/claw-frontend/src/hooks/memory/use-reject-memory-suggestion.ts create mode 100644 apps/claw-memory-service/prisma/migrations/20260524000000_memory_context_v2/migration.sql create mode 100644 apps/claw-memory-service/src/common/constants/memory-retrieval.constants.ts create mode 100644 apps/claw-memory-service/src/common/constants/memory-sensitivity.constants.ts create mode 100644 apps/claw-memory-service/src/common/enums/context-pack-item-type.enum.ts create mode 100644 apps/claw-memory-service/src/common/enums/context-pack-scope.enum.ts create mode 100644 apps/claw-memory-service/src/common/enums/context-pack-visibility.enum.ts create mode 100644 apps/claw-memory-service/src/common/enums/memory-audit-action.enum.ts create mode 100644 apps/claw-memory-service/src/common/enums/memory-retention.enum.ts create mode 100644 apps/claw-memory-service/src/common/enums/memory-scope.enum.ts create mode 100644 apps/claw-memory-service/src/common/enums/memory-sensitivity.enum.ts create mode 100644 apps/claw-memory-service/src/common/enums/memory-source.enum.ts create mode 100644 apps/claw-memory-service/src/common/enums/memory-suggestion-status.enum.ts create mode 100644 apps/claw-memory-service/src/common/enums/retrieval-reason.enum.ts create mode 100644 apps/claw-memory-service/src/common/utilities/date-coerce.utility.ts create mode 100644 apps/claw-memory-service/src/modules/memory-audit/controllers/memory-audit.controller.ts create mode 100644 apps/claw-memory-service/src/modules/memory-audit/repositories/memory-audit-log.repository.ts create mode 100644 apps/claw-memory-service/src/modules/memory-audit/services/memory-audit.service.ts create mode 100644 apps/claw-memory-service/src/modules/memory-audit/types/memory-audit.types.ts create mode 100644 apps/claw-memory-service/src/modules/memory-preferences/constants/memory-preference.constants.ts create mode 100644 apps/claw-memory-service/src/modules/memory-preferences/controllers/memory-preferences.controller.ts create mode 100644 apps/claw-memory-service/src/modules/memory-preferences/dto/upsert-memory-preference.dto.ts create mode 100644 apps/claw-memory-service/src/modules/memory-preferences/repositories/memory-preference.repository.ts create mode 100644 apps/claw-memory-service/src/modules/memory-preferences/services/memory-preference.service.ts create mode 100644 apps/claw-memory-service/src/modules/memory-preferences/types/memory-preference.types.ts create mode 100644 apps/claw-memory-service/src/modules/memory-suggestions/controllers/memory-suggestions.controller.ts create mode 100644 apps/claw-memory-service/src/modules/memory-suggestions/dto/approve-suggestion.dto.ts create mode 100644 apps/claw-memory-service/src/modules/memory-suggestions/dto/bulk-approve-suggestions.dto.ts create mode 100644 apps/claw-memory-service/src/modules/memory-suggestions/dto/list-memory-suggestions-query.dto.ts create mode 100644 apps/claw-memory-service/src/modules/memory-suggestions/dto/reject-suggestion.dto.ts create mode 100644 apps/claw-memory-service/src/modules/memory-suggestions/repositories/memory-suggestion.repository.ts create mode 100644 apps/claw-memory-service/src/modules/memory-suggestions/services/memory-suggestion.service.ts create mode 100644 apps/claw-memory-service/src/modules/memory-suggestions/types/memory-suggestion.types.ts create mode 100644 apps/claw-memory-service/src/modules/memory-usage/controllers/memory-usage.controller.ts create mode 100644 apps/claw-memory-service/src/modules/memory-usage/repositories/memory-usage.repository.ts create mode 100644 apps/claw-memory-service/src/modules/memory-usage/services/memory-usage.service.ts create mode 100644 apps/claw-memory-service/src/modules/memory-usage/types/memory-usage.types.ts create mode 100644 apps/claw-memory-service/src/modules/memory/controllers/memory-retrieval.controller.ts create mode 100644 apps/claw-memory-service/src/modules/memory/dto/retrieve.dto.ts create mode 100644 apps/claw-memory-service/src/modules/memory/dto/search-memories.dto.ts create mode 100644 apps/claw-memory-service/src/modules/memory/managers/memory-sensitivity.manager.ts create mode 100644 apps/claw-memory-service/src/modules/memory/services/memory-retrieval.service.ts create mode 100644 apps/claw-memory-service/src/modules/memory/types/memory-sensitivity.types.ts create mode 100644 docs/03-architecture/memory-context-integration.md create mode 100644 docs/13-adr/033-memory-suggestion-queue.md create mode 100644 docs/13-adr/034-memory-scopes-and-sensitivity.md create mode 100644 docs/13-adr/035-context-pack-versioning.md create mode 100644 docs/13-adr/036-context-pack-scoping-and-sharing.md create mode 100644 docs/13-adr/037-unified-retrieval-bundle.md create mode 100644 docs/13-adr/038-context-receipt-store.md create mode 100644 packages/shared-types/src/enums/context-pack-item-type.enum.ts create mode 100644 packages/shared-types/src/enums/context-pack-scope.enum.ts create mode 100644 packages/shared-types/src/enums/context-pack-visibility.enum.ts create mode 100644 packages/shared-types/src/enums/memory-audit-action.enum.ts create mode 100644 packages/shared-types/src/enums/memory-retention.enum.ts create mode 100644 packages/shared-types/src/enums/memory-scope.enum.ts create mode 100644 packages/shared-types/src/enums/memory-sensitivity.enum.ts create mode 100644 packages/shared-types/src/enums/memory-source.enum.ts create mode 100644 packages/shared-types/src/enums/memory-suggestion-status.enum.ts create mode 100644 packages/shared-types/src/enums/retrieval-reason.enum.ts create mode 100644 packages/shared-types/src/types/retrieval.type.ts diff --git a/.env.example b/.env.example index 7d7f6926..c2e7c92c 100644 --- a/.env.example +++ b/.env.example @@ -166,6 +166,29 @@ OLLAMA_FLASH_ATTENTION=1 OLLAMA_KV_CACHE_TYPE=q8_0 MEMORY_EXTRACTION_MODEL=AUTO +# ============================================================================= +# Memory + Context V2 Flagship +# ============================================================================= +# Master flag for the V2 control center; v1 endpoints stay live regardless. +MEMORY_V2_ENABLED=true +CONTEXT_V2_ENABLED=true +RETRIEVAL_V2_ENABLED=true +# Ollama models that back the V2 sensitivity and embedding subsystems. +MEMORY_SENSITIVITY_MODEL=gemma3:4b +MEMORY_EMBEDDING_MODEL=nomic-embed-text +CONTEXT_EMBEDDING_MODEL=nomic-embed-text +CONTEXT_COMPRESSION_MODEL=gemma3:4b +# Per-user defaults for the suggestion queue (auto-approve cut-off) and retention sweep. +MEMORY_AUTO_APPROVE_DEFAULT=0.85 +MEMORY_RETENTION_SWEEP_INTERVAL_MS=3600000 +MEMORY_SUGGESTION_TTL_DAYS=30 +CONTEXT_VERSION_RETENTION_COUNT=20 +CONTEXT_TOKEN_ESTIMATOR_MODE=char/4 +# Retrieval budgets used by `/internal/memories/retrieve`. +RETRIEVAL_MEMORY_SEMANTIC_BUDGET=5 +RETRIEVAL_CONTEXT_SEMANTIC_BUDGET=12 +RETRIEVAL_TOKEN_GUARD_PCT=0.4 + # ============================================================================= # File Service # ============================================================================= diff --git a/CLAUDE.md b/CLAUDE.md index 3281add5..d3305ea5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ apps/ claw-chat-service/ # Port 4002, PG claw_chat — threads, messages, context assembly, execution claw-connector-service/ # Port 4003, PG claw_connectors — 7 providers (OpenAI, Anthropic, Gemini, Bedrock, DeepSeek, Ollama, Grok), health, model sync claw-routing-service/ # Port 4004, PG claw_routing — 7 modes, Ollama-assisted AUTO, policies - claw-memory-service/ # Port 4005, PG claw_memory — memory CRUD, extraction, context packs + claw-memory-service/ # Port 4005, PG claw_memory — memory CRUD + suggestion queue, extraction, sensitivity classifier, retrieval bundle, audit, usage telemetry, preferences, context packs (scopes, versions, attachments, templates) claw-file-service/ # Port 4006, PG claw_files — upload, chunking (JSON/CSV/MD/text) claw-audit-service/ # Port 4007, MongoDB — 10 audit events, usage ledger claw-ollama-service/ # Port 4008, PG claw_ollama — model management, roles, generation @@ -506,9 +506,10 @@ Mitigations (in priority order): ### Chat (PostgreSQL) -- `ChatThread` — userId, title, routingMode, preferredProvider/Model, contextPackIds[], systemPrompt, temperature, maxTokens +- `ChatThread` — userId, title, routingMode, preferredProvider/Model, contextPackIds[], systemPrompt, temperature, maxTokens, **V2 Integration**: useMemory, useContext (per-thread toggles) - `ChatMessage` — threadId, role, content, provider, model, routingMode, inputTokens, outputTokens, latencyMs, feedback, metadata(JSON) - `MessageAttachment` — messageId, fileId, type +- `ChatMessageContextReceipt` (**V2 Integration**) — messageId UNIQUE, threadId, userId, payloadJson (RetrievalBundle: memories, packItems, assemblyOrder, tokenBudget, warnings), createdAt — backs "why was this used?" ### Connectors (PostgreSQL) @@ -523,9 +524,17 @@ Mitigations (in priority order): ### Memory (PostgreSQL + pgvector) -- `MemoryRecord` — userId, type (FACT/PREFERENCE/INSTRUCTION/SUMMARY), content, sourceThreadId/MessageId, isEnabled -- `ContextPack` — name, description, scope -- `ContextPackItem` — type, content, fileId, sortOrder +- `MemoryRecord` — userId, type (FACT/PREFERENCE/INSTRUCTION/SUMMARY), content, sourceThreadId/MessageId, isEnabled, **V2**: scope (USER/THREAD/WORKSPACE/PROJECT), scopeRef, tags, category, priority, confidence, source (USER_MANUAL/AI_EXTRACTED/AUTOMATION_LEARNING/IMPORTED), sensitivity (NORMAL/SENSITIVE/REDACTED), retentionPolicy (PERMANENT/EXPIRING/AUTO_DECAY), expiresAt, pinned, pausedUntil, qualityScore, useCount, lastUsedAt, provenanceJson +- `MemorySuggestion` (**V2**) — userId, type, content, confidence, sensitivity, reason, status (PENDING/APPROVED/REJECTED/AUTO_APPROVED/DISMISSED/EXPIRED), decidedAt, decidedBy, resultingMemoryId, sourceThreadId/MessageId +- `MemoryUsage` (**V2**) — memoryId, userId, threadId, messageId, score, reason +- `MemoryAuditLog` (**V2**) — memoryId (nullable; row outlives deletion), userId, action (CREATED/UPDATED/DELETED/USED/APPROVED/REJECTED/TOGGLED/PAUSED/RESUMED/REDACTED/IMPORTED/EXPORTED), actor, details +- `MemoryPreference` (**V2**) — userId, pausedAll, autoApproveThreshold (default 0.85), defaultRetention, defaultExpiresInDays, redactByDefault +- `ContextPack` — name, description, scope, **V2**: scope (USER/WORKSPACE/PROJECT/THREAD enum), scopeRef, legacyScope (free-text back-compat), tags, visibility (PRIVATE/WORKSPACE/PUBLIC), isEnabled, pausedUntil, pinned, color, icon, version, templateId, ownerUserId, useCount, lastUsedAt, qualityScore +- `ContextPackItem` — type, content, fileId, sortOrder, **V2**: itemType (TEXT/FILE/URL/MARKDOWN/SNIPPET/MEMORY_REF), legacyType, url, memoryRefId, isEnabled, pinned, tokenCountEstimate, compressedSummary +- `ContextPackVersion` (**V2**) — packId, version, payloadJson, summary, changedBy, createdAt (immutable history, pruned at 20 per pack) +- `ContextPackUsage` (**V2**) — packId, userId, threadId, messageId, itemIdsUsed[], score +- `ContextPackAttachment` (**V2**) — packId, scope, scopeRef, attachedBy, isActive +- `ContextPackTemplate` (**V2**) — name, description, category, isSystem, payloadJson ### Files (PostgreSQL) @@ -566,6 +575,25 @@ Exchange: `claw.events` (topic, durable). DLQ + 3 retries with backoff. | connector.health_checked | connector | audit, routing | | routing.decision_made | routing | audit | | memory.extracted | memory | audit | +| memory.suggested | memory | audit | +| memory.approved | memory | audit | +| memory.rejected | memory | audit | +| memory.used | memory | audit | +| memory.forgotten | memory | audit | +| memory.paused | memory | audit | +| memory.redacted | memory | audit | +| context_pack.created | memory | audit | +| context_pack.updated | memory | audit | +| context_pack.deleted | memory | audit | +| context_pack.attached | memory | audit | +| context_pack.detached | memory | audit | +| context_pack.used | memory | audit | +| context_pack.version_created | memory | audit | +| context_pack.version_reverted | memory | audit | +| context_pack.shared | memory | audit | +| context.receipt_written | chat | audit | +| chat_thread.memory_toggled | chat | audit | +| chat_thread.context_toggled | chat | audit | | file.uploaded/chunked | file | — | | log.server | all services | server-logs | | image.generated | image | audit | @@ -1066,6 +1094,21 @@ Single root `.env` (copy from `.env.example`). Groups: - WEBHOOK_CONNECTOR_REQUESTS_PER_MINUTE (default 60) — per-connector cap on incoming webhook delivery rate (Stream 11.4, in-memory sliding window; over-cap returns RATE_LIMITED rejection) - AUTO_SUGGEST_INBOX_REPLY_CRON (default `0 */15 * * * *`) — cron for the Gmail INBOX_REPLY collector that emits DRAFT candidates (Stream 12.2) - AUTO_SUGGEST_INBOX_REPLY_LOOKBACK_HOURS (default 48) — how far back to scan Gmail messages for inbox-reply candidates +- Memory + Context V2 Flagship (2026-05-24, ADRs 033–038, docs/03-architecture/memory-context-integration.md): + - MEMORY_V2_ENABLED (default true) — master flag for the V2 control center; v1 endpoints stay live regardless + - CONTEXT_V2_ENABLED (default true) + - RETRIEVAL_V2_ENABLED (default true) — gates the unified `POST /internal/memories/retrieve` endpoint + - MEMORY_SENSITIVITY_MODEL (default `gemma3:4b`) — ambiguous-case sensitivity classifier (regex pre-filter ships in V2; Ollama call is a follow-up enhancement) + - MEMORY_EMBEDDING_MODEL / CONTEXT_EMBEDDING_MODEL (default `nomic-embed-text`) + - CONTEXT_COMPRESSION_MODEL (default `gemma3:4b`) + - MEMORY_AUTO_APPROVE_DEFAULT (default 0.85) — per-user `memory_preferences.autoApproveThreshold` default; only fires for sensitivity=NORMAL + - MEMORY_RETENTION_SWEEP_INTERVAL_MS (default 3600000) — hourly retention sweep + - MEMORY_SUGGESTION_TTL_DAYS (default 30) — auto-expire pending suggestions + - CONTEXT_VERSION_RETENTION_COUNT (default 20) — versions kept per pack + - CONTEXT_TOKEN_ESTIMATOR_MODE (default `char/4`) + - RETRIEVAL_MEMORY_SEMANTIC_BUDGET (default 5) — top-K memories per retrieval + - RETRIEVAL_CONTEXT_SEMANTIC_BUDGET (default 12) — top-K pack items per retrieval + - RETRIEVAL_TOKEN_GUARD_PCT (default 0.4) — fraction of token budget memory+context may consume --- diff --git a/apps/claw-chat-service/prisma/migrations/20260524000000_chat_context_v2/migration.sql b/apps/claw-chat-service/prisma/migrations/20260524000000_chat_context_v2/migration.sql new file mode 100644 index 00000000..cefa09cf --- /dev/null +++ b/apps/claw-chat-service/prisma/migrations/20260524000000_chat_context_v2/migration.sql @@ -0,0 +1,23 @@ +-- Integration V2 (Memory + Context) — chat-service additions. +-- Adds per-thread memory/context toggles and the assembled-context receipt +-- table that backs the "why did the AI know this?" surface. + +ALTER TABLE "chat_threads" + ADD COLUMN "use_memory" BOOLEAN NOT NULL DEFAULT true, + ADD COLUMN "use_context" BOOLEAN NOT NULL DEFAULT true; + +CREATE TABLE "chat_message_context_receipts" ( + "id" TEXT NOT NULL, + "message_id" TEXT NOT NULL, + "thread_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "payload_json" JSONB NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "chat_message_context_receipts_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "chat_message_context_receipts_message_id_unique" + ON "chat_message_context_receipts"("message_id"); +CREATE INDEX "chat_message_context_receipts_thread_id_idx" + ON "chat_message_context_receipts"("thread_id"); +CREATE INDEX "chat_message_context_receipts_userId_createdAt_idx" + ON "chat_message_context_receipts"("user_id", "created_at"); diff --git a/apps/claw-chat-service/prisma/schema.prisma b/apps/claw-chat-service/prisma/schema.prisma index 16d925b6..122a862c 100644 --- a/apps/claw-chat-service/prisma/schema.prisma +++ b/apps/claw-chat-service/prisma/schema.prisma @@ -48,6 +48,9 @@ model ChatThread { judgeModel String? @map("judge_model") qualityThreshold Float? @map("quality_threshold") maxReRouteAttempts Int? @map("max_reroute_attempts") + // === Integration V2 — per-thread memory/context switches === + useMemory Boolean @default(true) @map("use_memory") + useContext Boolean @default(true) @map("use_context") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -58,6 +61,19 @@ model ChatThread { @@map("chat_threads") } +model ChatMessageContextReceipt { + id String @id @default(cuid()) + messageId String @unique @map("message_id") + threadId String @map("thread_id") + userId String @map("user_id") + payloadJson Json @map("payload_json") + createdAt DateTime @default(now()) @map("created_at") + + @@index([threadId]) + @@index([userId, createdAt]) + @@map("chat_message_context_receipts") +} + model ChatMessage { id String @id @default(cuid()) threadId String @map("thread_id") diff --git a/apps/claw-chat-service/src/app/app.module.ts b/apps/claw-chat-service/src/app/app.module.ts index 74b634b5..637e97be 100644 --- a/apps/claw-chat-service/src/app/app.module.ts +++ b/apps/claw-chat-service/src/app/app.module.ts @@ -16,6 +16,7 @@ import { LoggingInterceptor } from './interceptors/logging.interceptor'; import { HealthModule } from '../modules/health/health.module'; import { ChatThreadsModule } from '../modules/chat-threads/chat-threads.module'; import { ChatMessagesModule } from '../modules/chat-messages/chat-messages.module'; +import { ContextReceiptsModule } from '../modules/context-receipts/context-receipts.module'; @Module({ imports: [ @@ -61,6 +62,7 @@ import { ChatMessagesModule } from '../modules/chat-messages/chat-messages.modul HealthModule, ChatThreadsModule, ChatMessagesModule, + ContextReceiptsModule, ThrottlerModule.forRoot([ { ttl: Number(process.env['THROTTLE_TTL'] ?? 60000), diff --git a/apps/claw-chat-service/src/common/utilities/context-receipt-json.utility.ts b/apps/claw-chat-service/src/common/utilities/context-receipt-json.utility.ts new file mode 100644 index 00000000..4abbc22b --- /dev/null +++ b/apps/claw-chat-service/src/common/utilities/context-receipt-json.utility.ts @@ -0,0 +1,15 @@ +import type { RetrievalBundle } from '@claw/shared-types'; +import type { Prisma } from '../../generated/prisma'; + +export function bundleToInputJson(bundle: RetrievalBundle): Prisma.InputJsonValue { + // RetrievalBundle is a plain JSON-shaped DTO (no Date / Function / BigInt / + // undefined). Round-tripping through JSON normalizes the structure and + // satisfies Prisma's InputJsonValue contract without an `as unknown as` cast. + return JSON.parse(JSON.stringify(bundle)) as Prisma.InputJsonValue; +} + +export function inputJsonToBundle(payload: Prisma.JsonValue): RetrievalBundle { + // Inverse of bundleToInputJson — payload was written via the helper above + // so we know it matches the bundle shape. + return JSON.parse(JSON.stringify(payload)) as RetrievalBundle; +} diff --git a/apps/claw-chat-service/src/modules/context-receipts/context-receipts.module.ts b/apps/claw-chat-service/src/modules/context-receipts/context-receipts.module.ts new file mode 100644 index 00000000..1e66a498 --- /dev/null +++ b/apps/claw-chat-service/src/modules/context-receipts/context-receipts.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ContextReceiptController } from './controllers/context-receipt.controller'; +import { ContextReceiptRepository } from './repositories/context-receipt.repository'; +import { ContextReceiptService } from './services/context-receipt.service'; + +@Module({ + controllers: [ContextReceiptController], + providers: [ContextReceiptRepository, ContextReceiptService], + exports: [ContextReceiptService], +}) +export class ContextReceiptsModule {} diff --git a/apps/claw-chat-service/src/modules/context-receipts/controllers/context-receipt.controller.ts b/apps/claw-chat-service/src/modules/context-receipts/controllers/context-receipt.controller.ts new file mode 100644 index 00000000..51933d14 --- /dev/null +++ b/apps/claw-chat-service/src/modules/context-receipts/controllers/context-receipt.controller.ts @@ -0,0 +1,18 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import type { ContextReceipt } from '@claw/shared-types'; +import { CurrentUser } from '../../../app/decorators/current-user.decorator'; +import type { AuthenticatedUser } from '../../../common/types'; +import { ContextReceiptService } from '../services/context-receipt.service'; + +@Controller('chat-messages') +export class ContextReceiptController { + constructor(private readonly service: ContextReceiptService) {} + + @Get(':id/context-receipt') + async getReceipt( + @Param('id') id: string, + @CurrentUser() user: AuthenticatedUser, + ): Promise { + return this.service.getByMessageId(id, user.id); + } +} diff --git a/apps/claw-chat-service/src/modules/context-receipts/repositories/context-receipt.repository.ts b/apps/claw-chat-service/src/modules/context-receipts/repositories/context-receipt.repository.ts new file mode 100644 index 00000000..e745ab08 --- /dev/null +++ b/apps/claw-chat-service/src/modules/context-receipts/repositories/context-receipt.repository.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; +import type { ChatMessageContextReceipt } from '../../../generated/prisma'; +import { PrismaService } from '../../../infrastructure/database/prisma/prisma.service'; +import { bundleToInputJson } from '../../../common/utilities/context-receipt-json.utility'; +import type { WriteContextReceiptInput } from '../types/context-receipt.types'; + +@Injectable() +export class ContextReceiptRepository { + constructor(private readonly prisma: PrismaService) {} + + async upsert(input: WriteContextReceiptInput): Promise { + const payloadJson = bundleToInputJson(input.bundle); + return this.prisma.chatMessageContextReceipt.upsert({ + where: { messageId: input.messageId }, + update: { payloadJson, threadId: input.threadId, userId: input.userId }, + create: { + messageId: input.messageId, + threadId: input.threadId, + userId: input.userId, + payloadJson, + }, + }); + } + + async findByMessageId(messageId: string): Promise { + return this.prisma.chatMessageContextReceipt.findUnique({ where: { messageId } }); + } + + async findByThreadId(threadId: string, limit = 50): Promise { + return this.prisma.chatMessageContextReceipt.findMany({ + where: { threadId }, + orderBy: { createdAt: 'desc' }, + take: limit, + }); + } +} diff --git a/apps/claw-chat-service/src/modules/context-receipts/services/context-receipt.service.ts b/apps/claw-chat-service/src/modules/context-receipts/services/context-receipt.service.ts new file mode 100644 index 00000000..ed603b5a --- /dev/null +++ b/apps/claw-chat-service/src/modules/context-receipts/services/context-receipt.service.ts @@ -0,0 +1,72 @@ +import { HttpStatus, Injectable, Logger } from '@nestjs/common'; +import { RabbitMQService } from '@claw/shared-rabbitmq'; +import { type ContextReceipt, EventPattern, type RetrievalBundle } from '@claw/shared-types'; +import { BusinessException, EntityNotFoundException } from '../../../common/errors'; +import { inputJsonToBundle } from '../../../common/utilities/context-receipt-json.utility'; +import { ContextReceiptRepository } from '../repositories/context-receipt.repository'; + +@Injectable() +export class ContextReceiptService { + private readonly logger = new Logger(ContextReceiptService.name); + + constructor( + private readonly repo: ContextReceiptRepository, + private readonly rabbit: RabbitMQService, + ) {} + + async write( + messageId: string, + threadId: string, + userId: string, + bundle: RetrievalBundle, + ): Promise { + if ( + bundle.memories.length === 0 && + bundle.packItems.length === 0 && + bundle.warnings.length === 0 + ) { + this.logger.debug(`write: skipping empty receipt for messageId=${messageId}`); + return; + } + this.logger.debug( + `write: messageId=${messageId} memories=${String(bundle.memories.length)} packItems=${String(bundle.packItems.length)}`, + ); + try { + await this.repo.upsert({ messageId, threadId, userId, bundle }); + void this.rabbit.publish(EventPattern.CONTEXT_RECEIPT_WRITTEN, { + messageId, + threadId, + userId, + memoryCount: bundle.memories.length, + packItemCount: bundle.packItems.length, + tokenBudgetUsed: bundle.tokenBudgetUsed, + timestamp: new Date().toISOString(), + }); + } catch (error) { + const msg = error instanceof Error ? error.message : 'unknown'; + this.logger.warn(`write: failed to persist receipt — ${msg}`); + } + } + + async getByMessageId(messageId: string, userId: string): Promise { + const row = await this.repo.findByMessageId(messageId); + if (!row) { + throw new EntityNotFoundException('ChatMessageContextReceipt', messageId); + } + if (row.userId !== userId) { + throw new BusinessException( + 'You do not have access to this receipt', + 'FORBIDDEN_RECEIPT_ACCESS', + HttpStatus.FORBIDDEN, + ); + } + const bundle = inputJsonToBundle(row.payloadJson); + return { + ...bundle, + messageId: row.messageId, + threadId: row.threadId, + userId: row.userId, + createdAt: row.createdAt.toISOString(), + }; + } +} diff --git a/apps/claw-chat-service/src/modules/context-receipts/types/context-receipt.types.ts b/apps/claw-chat-service/src/modules/context-receipts/types/context-receipt.types.ts new file mode 100644 index 00000000..df9c8652 --- /dev/null +++ b/apps/claw-chat-service/src/modules/context-receipts/types/context-receipt.types.ts @@ -0,0 +1,8 @@ +import type { RetrievalBundle } from '@claw/shared-types'; + +export type WriteContextReceiptInput = { + messageId: string; + threadId: string; + userId: string; + bundle: RetrievalBundle; +}; diff --git a/apps/claw-frontend/src/app/(portal)/memory/page.tsx b/apps/claw-frontend/src/app/(portal)/memory/page.tsx index b79c5188..cef83f9b 100644 --- a/apps/claw-frontend/src/app/(portal)/memory/page.tsx +++ b/apps/claw-frontend/src/app/(portal)/memory/page.tsx @@ -5,9 +5,13 @@ import { Brain, Plus } from 'lucide-react'; import { EmptyState } from '@/components/common/empty-state'; import { LoadingSpinner } from '@/components/common/loading-spinner'; import { PageHeader } from '@/components/common/page-header'; +import { AuditList } from '@/components/memory/audit-list'; import { MemoryCard } from '@/components/memory/memory-card'; import { MemoryForm } from '@/components/memory/memory-form'; +import { SuggestionsList } from '@/components/memory/suggestions-list'; +import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; import { Select, SelectContent, @@ -15,19 +19,35 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { MEMORY_FILTER_OPTIONS } from '@/constants'; +import { MemoryScope, MemorySensitivity, MemorySource, MemoryTab } from '@/enums'; import { useMemoryPage } from '@/hooks/memory/use-memory-page'; import { useTranslation } from '@/lib/i18n'; import type { MemoryFilterType } from '@/types'; -export default function MemoryPage() { +export default function MemoryPage(): React.ReactElement { const { + activeTab, + setActiveTab, memories, isLoading, isError, error, + suggestions, + isSuggestionsLoading, + auditEntries, + isAuditLoading, filterType, setFilterType, + filterScope, + setFilterScope, + filterSource, + setFilterSource, + filterSensitivity, + setFilterSensitivity, + search, + setSearch, isFormOpen, setIsFormOpen, editingMemory, @@ -36,8 +56,12 @@ export default function MemoryPage() { handleFormSubmit, handleToggle, handleDelete, + handleApproveSuggestion, + handleRejectSuggestion, isFormPending, isTogglePending, + isApprovingSuggestion, + isRejectingSuggestion, } = useMemoryPage(); const { t } = useTranslation(); @@ -59,7 +83,39 @@ export default function MemoryPage() { title={t('memory.title')} description={t('memory.description')} actions={ -
+ + } + /> + + setActiveTab(value as MemoryTab)} + className="flex flex-1 flex-col gap-4" + > + + {t('memory.tabSaved')} + + {t('memory.tabSuggestions')} + {suggestions.length > 0 ? ( + + {suggestions.length} + + ) : null} + + {t('memory.tabAudit')} + + + +
+ setSearch(e.target.value)} + className="w-[260px]" + /> - + + +
- } - /> - - {isLoading && } - {!isLoading && memories.length === 0 && ( - - - {t('memory.addMemory')} - - } - /> - )} + {isLoading && } - {!isLoading && memories.length > 0 && ( -
- {memories.map((memory) => ( - + + {t('memory.addMemory')} + + } /> - ))} -
- )} + )} + + {!isLoading && memories.length > 0 && ( +
+ {memories.map((memory) => ( + + ))} +
+ )} +
+ + + + + + + + +
; + } + + if (entries.length === 0) { + return ( + + + {t('memory.noAudit')} + + + ); + } + + return ( +
+ {entries.map((entry) => ( + + +
+ + {entry.action} + + {entry.memoryId ? ( + {entry.memoryId} + ) : ( + + {t('memory.auditNoMemoryId')} + + )} +
+ {formatDate(entry.createdAt)} +
+
+ ))} +
+ ); +} diff --git a/apps/claw-frontend/src/components/memory/suggestions-list.tsx b/apps/claw-frontend/src/components/memory/suggestions-list.tsx new file mode 100644 index 00000000..73b7cab2 --- /dev/null +++ b/apps/claw-frontend/src/components/memory/suggestions-list.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { Check, ShieldAlert, X } from 'lucide-react'; + +import { LoadingSpinner } from '@/components/common/loading-spinner'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { useTranslation } from '@/lib/i18n'; +import type { SuggestionsListProps } from '@/types/component.types'; +import { formatDate } from '@/utilities'; + +export function SuggestionsList(props: SuggestionsListProps): React.ReactElement { + const { suggestions, isLoading, isPending, onApprove, onReject } = props; + const { t } = useTranslation(); + + if (isLoading) { + return ; + } + + if (suggestions.length === 0) { + return ( + + + {t('memory.noSuggestions')} + + + ); + } + + return ( +
+ {suggestions.map((suggestion) => ( + + +
+
+ + {suggestion.type} + + {suggestion.sensitivity !== 'NORMAL' && ( + + + {suggestion.sensitivity} + + )} + + {t('memory.suggestionConfidence', { + value: String(Math.round(suggestion.confidence * 100)), + })} + +
+ + {formatDate(suggestion.createdAt)} + +
+
+ +

{suggestion.content}

+ {suggestion.reason ? ( +

+ {t('memory.suggestionReason', { value: suggestion.reason })} +

+ ) : null} +
+ + + +
+
+
+ ))} +
+ ); +} diff --git a/apps/claw-frontend/src/enums/context-pack-item-type-v2.enum.ts b/apps/claw-frontend/src/enums/context-pack-item-type-v2.enum.ts new file mode 100644 index 00000000..c99e2027 --- /dev/null +++ b/apps/claw-frontend/src/enums/context-pack-item-type-v2.enum.ts @@ -0,0 +1,11 @@ +// Backend-aligned V2 enum (TEXT/FILE/URL/MARKDOWN/SNIPPET/MEMORY_REF). +// The legacy `ContextPackItemType` (NOTE/INSTRUCTION/FILE_REFERENCE) remains +// to drive the v1 display labels; the V2 grid uses this enum instead. +export enum ContextPackItemTypeV2 { + TEXT = 'TEXT', + FILE = 'FILE', + URL = 'URL', + MARKDOWN = 'MARKDOWN', + SNIPPET = 'SNIPPET', + MEMORY_REF = 'MEMORY_REF', +} diff --git a/apps/claw-frontend/src/enums/context-pack-scope.enum.ts b/apps/claw-frontend/src/enums/context-pack-scope.enum.ts new file mode 100644 index 00000000..ea055041 --- /dev/null +++ b/apps/claw-frontend/src/enums/context-pack-scope.enum.ts @@ -0,0 +1,6 @@ +export enum ContextPackScope { + USER = 'USER', + WORKSPACE = 'WORKSPACE', + PROJECT = 'PROJECT', + THREAD = 'THREAD', +} diff --git a/apps/claw-frontend/src/enums/context-pack-visibility.enum.ts b/apps/claw-frontend/src/enums/context-pack-visibility.enum.ts new file mode 100644 index 00000000..a6b06ff1 --- /dev/null +++ b/apps/claw-frontend/src/enums/context-pack-visibility.enum.ts @@ -0,0 +1,5 @@ +export enum ContextPackVisibility { + PRIVATE = 'PRIVATE', + WORKSPACE = 'WORKSPACE', + PUBLIC = 'PUBLIC', +} diff --git a/apps/claw-frontend/src/enums/index.ts b/apps/claw-frontend/src/enums/index.ts index 946abf95..92e6878e 100644 --- a/apps/claw-frontend/src/enums/index.ts +++ b/apps/claw-frontend/src/enums/index.ts @@ -12,6 +12,16 @@ export { MemoryFilterValue } from './memory-filter-value.enum'; export { MemoryType } from './memory-type.enum'; export { FileIngestionStatus } from './file-ingestion-status.enum'; export { ContextPackItemType } from './context-pack-item-type.enum'; +export { ContextPackItemTypeV2 } from './context-pack-item-type-v2.enum'; +export { ContextPackScope } from './context-pack-scope.enum'; +export { ContextPackVisibility } from './context-pack-visibility.enum'; +export { MemoryScope } from './memory-scope.enum'; +export { MemorySource } from './memory-source.enum'; +export { MemorySensitivity } from './memory-sensitivity.enum'; +export { MemoryRetention } from './memory-retention.enum'; +export { MemorySuggestionStatus } from './memory-suggestion-status.enum'; +export { MemoryAuditAction } from './memory-audit-action.enum'; +export { MemoryTab } from './memory-tab.enum'; export { BadgeVariant } from './badge-variant.enum'; export { ConnectorAuthType } from './connector-auth-type.enum'; export { ModelLifecycle } from './model-lifecycle.enum'; diff --git a/apps/claw-frontend/src/enums/memory-audit-action.enum.ts b/apps/claw-frontend/src/enums/memory-audit-action.enum.ts new file mode 100644 index 00000000..d0207302 --- /dev/null +++ b/apps/claw-frontend/src/enums/memory-audit-action.enum.ts @@ -0,0 +1,14 @@ +export enum MemoryAuditAction { + CREATED = 'CREATED', + UPDATED = 'UPDATED', + DELETED = 'DELETED', + USED = 'USED', + APPROVED = 'APPROVED', + REJECTED = 'REJECTED', + TOGGLED = 'TOGGLED', + PAUSED = 'PAUSED', + RESUMED = 'RESUMED', + REDACTED = 'REDACTED', + IMPORTED = 'IMPORTED', + EXPORTED = 'EXPORTED', +} diff --git a/apps/claw-frontend/src/enums/memory-retention.enum.ts b/apps/claw-frontend/src/enums/memory-retention.enum.ts new file mode 100644 index 00000000..baebd522 --- /dev/null +++ b/apps/claw-frontend/src/enums/memory-retention.enum.ts @@ -0,0 +1,5 @@ +export enum MemoryRetention { + PERMANENT = 'PERMANENT', + EXPIRING = 'EXPIRING', + AUTO_DECAY = 'AUTO_DECAY', +} diff --git a/apps/claw-frontend/src/enums/memory-scope.enum.ts b/apps/claw-frontend/src/enums/memory-scope.enum.ts new file mode 100644 index 00000000..73674772 --- /dev/null +++ b/apps/claw-frontend/src/enums/memory-scope.enum.ts @@ -0,0 +1,6 @@ +export enum MemoryScope { + USER = 'USER', + THREAD = 'THREAD', + WORKSPACE = 'WORKSPACE', + PROJECT = 'PROJECT', +} diff --git a/apps/claw-frontend/src/enums/memory-sensitivity.enum.ts b/apps/claw-frontend/src/enums/memory-sensitivity.enum.ts new file mode 100644 index 00000000..936112e2 --- /dev/null +++ b/apps/claw-frontend/src/enums/memory-sensitivity.enum.ts @@ -0,0 +1,5 @@ +export enum MemorySensitivity { + NORMAL = 'NORMAL', + SENSITIVE = 'SENSITIVE', + REDACTED = 'REDACTED', +} diff --git a/apps/claw-frontend/src/enums/memory-source.enum.ts b/apps/claw-frontend/src/enums/memory-source.enum.ts new file mode 100644 index 00000000..29e007dc --- /dev/null +++ b/apps/claw-frontend/src/enums/memory-source.enum.ts @@ -0,0 +1,6 @@ +export enum MemorySource { + USER_MANUAL = 'USER_MANUAL', + AI_EXTRACTED = 'AI_EXTRACTED', + AUTOMATION_LEARNING = 'AUTOMATION_LEARNING', + IMPORTED = 'IMPORTED', +} diff --git a/apps/claw-frontend/src/enums/memory-suggestion-status.enum.ts b/apps/claw-frontend/src/enums/memory-suggestion-status.enum.ts new file mode 100644 index 00000000..bd13a774 --- /dev/null +++ b/apps/claw-frontend/src/enums/memory-suggestion-status.enum.ts @@ -0,0 +1,8 @@ +export enum MemorySuggestionStatus { + PENDING = 'PENDING', + APPROVED = 'APPROVED', + REJECTED = 'REJECTED', + AUTO_APPROVED = 'AUTO_APPROVED', + DISMISSED = 'DISMISSED', + EXPIRED = 'EXPIRED', +} diff --git a/apps/claw-frontend/src/enums/memory-tab.enum.ts b/apps/claw-frontend/src/enums/memory-tab.enum.ts new file mode 100644 index 00000000..dca6c553 --- /dev/null +++ b/apps/claw-frontend/src/enums/memory-tab.enum.ts @@ -0,0 +1,5 @@ +export enum MemoryTab { + SAVED = 'saved', + SUGGESTIONS = 'suggestions', + AUDIT = 'audit', +} diff --git a/apps/claw-frontend/src/hooks/memory/use-approve-memory-suggestion.ts b/apps/claw-frontend/src/hooks/memory/use-approve-memory-suggestion.ts new file mode 100644 index 00000000..0c2e28b1 --- /dev/null +++ b/apps/claw-frontend/src/hooks/memory/use-approve-memory-suggestion.ts @@ -0,0 +1,15 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { memoryRepository } from '@/repositories/memory/memory.repository'; +import { queryKeys } from '@/repositories/shared/query-keys'; +import type { ApproveSuggestionRequest, MemoryRecord } from '@/types'; + +export function useApproveMemorySuggestion() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }) => memoryRepository.approveSuggestion(id, data), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.memory.all }); + }, + }); +} diff --git a/apps/claw-frontend/src/hooks/memory/use-delete-memory.ts b/apps/claw-frontend/src/hooks/memory/use-delete-memory.ts index b7a2bb80..aca8099f 100644 --- a/apps/claw-frontend/src/hooks/memory/use-delete-memory.ts +++ b/apps/claw-frontend/src/hooks/memory/use-delete-memory.ts @@ -11,11 +11,20 @@ export function useDeleteMemory() { const mutation = useMutation({ mutationFn: (id: string) => { - logger.info({ component: 'memory', action: 'delete-memory', message: 'Deleting memory', details: { memoryId: id } }); - return memoryRepository.deleteMemory(id); + logger.info({ + component: 'memory', + action: 'delete-memory', + message: 'Forgetting memory', + details: { memoryId: id }, + }); + return memoryRepository.deleteMemory(id, true); }, onSuccess: () => { - logger.info({ component: 'memory', action: 'delete-memory-success', message: 'Memory deleted' }); + logger.info({ + component: 'memory', + action: 'delete-memory-success', + message: 'Memory deleted', + }); void queryClient.invalidateQueries({ queryKey: queryKeys.memory.lists(), }); diff --git a/apps/claw-frontend/src/hooks/memory/use-memories.ts b/apps/claw-frontend/src/hooks/memory/use-memories.ts index f477e795..67fd1e9b 100644 --- a/apps/claw-frontend/src/hooks/memory/use-memories.ts +++ b/apps/claw-frontend/src/hooks/memory/use-memories.ts @@ -1,22 +1,39 @@ -import { useQuery } from "@tanstack/react-query"; +import { useQuery } from '@tanstack/react-query'; -import { memoryRepository } from "@/repositories/memory/memory.repository"; -import { queryKeys } from "@/repositories/shared/query-keys"; -import { logger } from "@/utilities"; +import { memoryRepository } from '@/repositories/memory/memory.repository'; +import { queryKeys } from '@/repositories/shared/query-keys'; +import { logger } from '@/utilities'; export function useMemories(filters: Record = {}) { const params: Record = {}; - if (filters["type"] !== undefined) { - params["type"] = String(filters["type"]); - } - if (filters["isEnabled"] !== undefined) { - params["isEnabled"] = String(filters["isEnabled"]); + for (const key of [ + 'type', + 'isEnabled', + 'scope', + 'scopeRef', + 'source', + 'sensitivity', + 'tag', + 'category', + 'pinnedOnly', + 'sort', + 'search', + ]) { + const value = filters[key]; + if (value !== undefined && value !== null && value !== '') { + params[key] = String(value); + } } const query = useQuery({ queryKey: queryKeys.memory.list(filters), queryFn: () => { - logger.debug({ component: 'memory', action: 'fetch-memories', message: 'Fetching memories', details: { filters: params } }); + logger.debug({ + component: 'memory', + action: 'fetch-memories', + message: 'Fetching memories', + details: { filters: params }, + }); return memoryRepository.getMemories(params); }, }); diff --git a/apps/claw-frontend/src/hooks/memory/use-memory-audit.ts b/apps/claw-frontend/src/hooks/memory/use-memory-audit.ts new file mode 100644 index 00000000..14089d70 --- /dev/null +++ b/apps/claw-frontend/src/hooks/memory/use-memory-audit.ts @@ -0,0 +1,32 @@ +import { useQuery } from '@tanstack/react-query'; + +import { memoryRepository } from '@/repositories/memory/memory.repository'; +import { queryKeys } from '@/repositories/shared/query-keys'; +import type { MemoryAuditLog } from '@/types'; + +export function useMemoryAuditAll(limit = 100) { + const query = useQuery({ + queryKey: queryKeys.memory.auditAll(), + queryFn: () => memoryRepository.listAudit(limit), + }); + return { + entries: query.data ?? [], + isLoading: query.isLoading, + isError: query.isError, + error: query.error, + }; +} + +export function useMemoryAuditForMemory(memoryId: string | null) { + const query = useQuery({ + queryKey: queryKeys.memory.audit(memoryId ?? 'none'), + queryFn: () => memoryRepository.listAuditForMemory(memoryId ?? ''), + enabled: memoryId !== null && memoryId !== '', + }); + return { + entries: query.data ?? [], + isLoading: query.isLoading, + isError: query.isError, + error: query.error, + }; +} diff --git a/apps/claw-frontend/src/hooks/memory/use-memory-page.ts b/apps/claw-frontend/src/hooks/memory/use-memory-page.ts index 89f6a1f0..eb5c419d 100644 --- a/apps/claw-frontend/src/hooks/memory/use-memory-page.ts +++ b/apps/claw-frontend/src/hooks/memory/use-memory-page.ts @@ -1,29 +1,67 @@ import { useCallback, useMemo, useState } from 'react'; -import { MemoryFilterValue } from '@/enums'; -import type { CreateMemoryRequest, MemoryFilterType, MemoryRecord } from '@/types'; +import { MemoryFilterValue, MemorySuggestionStatus, MemoryTab } from '@/enums'; +import type { + ApproveSuggestionRequest, + CreateMemoryRequest, + MemoryFilterType, + MemoryRecord, + RejectSuggestionRequest, +} from '@/types'; +import { useApproveMemorySuggestion } from './use-approve-memory-suggestion'; import { useCreateMemory } from './use-create-memory'; import { useDeleteMemory } from './use-delete-memory'; import { useMemories } from './use-memories'; +import { useMemoryAuditAll } from './use-memory-audit'; +import { useMemoryPreferences } from './use-memory-preferences'; +import { useMemorySuggestions } from './use-memory-suggestions'; +import { useRejectMemorySuggestion } from './use-reject-memory-suggestion'; import { useToggleMemory } from './use-toggle-memory'; import { useUpdateMemory } from './use-update-memory'; export function useMemoryPage() { + const [activeTab, setActiveTab] = useState(MemoryTab.SAVED); const [filterType, setFilterType] = useState(MemoryFilterValue.ALL); + const [filterScope, setFilterScope] = useState(''); + const [filterSource, setFilterSource] = useState(''); + const [filterSensitivity, setFilterSensitivity] = useState(''); + const [search, setSearch] = useState(''); const [isFormOpen, setIsFormOpen] = useState(false); const [editingMemory, setEditingMemory] = useState(null); - const filters = useMemo>( - () => (filterType !== MemoryFilterValue.ALL ? { type: filterType } : {}), - [filterType], - ); + const filters = useMemo>(() => { + const f: Record = {}; + if (filterType !== MemoryFilterValue.ALL) { + f['type'] = filterType; + } + if (filterScope) { + f['scope'] = filterScope; + } + if (filterSource) { + f['source'] = filterSource; + } + if (filterSensitivity) { + f['sensitivity'] = filterSensitivity; + } + if (search.trim().length > 0) { + f['search'] = search.trim(); + } + return f; + }, [filterType, filterScope, filterSource, filterSensitivity, search]); const { memories, isLoading, isError, error } = useMemories(filters); + const { suggestions, isLoading: isSuggestionsLoading } = useMemorySuggestions({ + status: MemorySuggestionStatus.PENDING, + }); + const { entries: auditEntries, isLoading: isAuditLoading } = useMemoryAuditAll(100); + const { preferences } = useMemoryPreferences(); const { createMemory, isPending: isCreatePending } = useCreateMemory(); const { updateMemory, isPending: isUpdatePending } = useUpdateMemory(); const { deleteMemory, isPending: isDeletePending } = useDeleteMemory(); const { toggleMemory, isPending: isTogglePending } = useToggleMemory(); + const approveSuggestion = useApproveMemorySuggestion(); + const rejectSuggestion = useRejectMemorySuggestion(); const handleOpenCreate = useCallback(() => { setEditingMemory(null); @@ -60,13 +98,42 @@ export function useMemoryPage() { [deleteMemory], ); + const handleApproveSuggestion = useCallback( + (id: string, data: ApproveSuggestionRequest = {}) => { + approveSuggestion.mutate({ id, data }); + }, + [approveSuggestion], + ); + + const handleRejectSuggestion = useCallback( + (id: string, data: RejectSuggestionRequest = {}) => { + rejectSuggestion.mutate({ id, data }); + }, + [rejectSuggestion], + ); + return { + activeTab, + setActiveTab, memories, isLoading, isError, error, + suggestions, + isSuggestionsLoading, + auditEntries, + isAuditLoading, + preferences, filterType, setFilterType, + filterScope, + setFilterScope, + filterSource, + setFilterSource, + filterSensitivity, + setFilterSensitivity, + search, + setSearch, isFormOpen, setIsFormOpen, editingMemory, @@ -75,8 +142,12 @@ export function useMemoryPage() { handleFormSubmit, handleToggle, handleDelete, + handleApproveSuggestion, + handleRejectSuggestion, isFormPending: isCreatePending || isUpdatePending, isDeletePending, isTogglePending, + isApprovingSuggestion: approveSuggestion.isPending, + isRejectingSuggestion: rejectSuggestion.isPending, }; } diff --git a/apps/claw-frontend/src/hooks/memory/use-memory-preferences.ts b/apps/claw-frontend/src/hooks/memory/use-memory-preferences.ts new file mode 100644 index 00000000..8e849b4f --- /dev/null +++ b/apps/claw-frontend/src/hooks/memory/use-memory-preferences.ts @@ -0,0 +1,28 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { memoryRepository } from '@/repositories/memory/memory.repository'; +import { queryKeys } from '@/repositories/shared/query-keys'; +import type { MemoryPreference, UpsertMemoryPreferenceRequest } from '@/types'; + +export function useMemoryPreferences() { + const query = useQuery({ + queryKey: queryKeys.memory.preferences(), + queryFn: () => memoryRepository.getPreferences(), + }); + return { + preferences: query.data, + isLoading: query.isLoading, + isError: query.isError, + error: query.error, + }; +} + +export function useUpsertMemoryPreferences() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data) => memoryRepository.upsertPreferences(data), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.memory.preferences() }); + }, + }); +} diff --git a/apps/claw-frontend/src/hooks/memory/use-memory-suggestions.ts b/apps/claw-frontend/src/hooks/memory/use-memory-suggestions.ts new file mode 100644 index 00000000..e1fc03f0 --- /dev/null +++ b/apps/claw-frontend/src/hooks/memory/use-memory-suggestions.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@tanstack/react-query'; + +import { memoryRepository } from '@/repositories/memory/memory.repository'; +import { queryKeys } from '@/repositories/shared/query-keys'; +import type { MemorySuggestion } from '@/types'; + +export function useMemorySuggestions(filters: Record = {}) { + const params: Record = {}; + if (filters['status'] !== undefined && filters['status'] !== '') { + params['status'] = String(filters['status']); + } + if (filters['limit'] !== undefined) { + params['limit'] = String(filters['limit']); + } + const query = useQuery({ + queryKey: queryKeys.memory.suggestions(filters), + queryFn: () => memoryRepository.listSuggestions(params), + }); + return { + suggestions: query.data ?? [], + isLoading: query.isLoading, + isError: query.isError, + error: query.error, + }; +} diff --git a/apps/claw-frontend/src/hooks/memory/use-reject-memory-suggestion.ts b/apps/claw-frontend/src/hooks/memory/use-reject-memory-suggestion.ts new file mode 100644 index 00000000..b5abd9b9 --- /dev/null +++ b/apps/claw-frontend/src/hooks/memory/use-reject-memory-suggestion.ts @@ -0,0 +1,15 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { memoryRepository } from '@/repositories/memory/memory.repository'; +import { queryKeys } from '@/repositories/shared/query-keys'; +import type { MemorySuggestion, RejectSuggestionRequest } from '@/types'; + +export function useRejectMemorySuggestion() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }) => memoryRepository.rejectSuggestion(id, data), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.memory.all }); + }, + }); +} diff --git a/apps/claw-frontend/src/lib/i18n/locales/ar.ts b/apps/claw-frontend/src/lib/i18n/locales/ar.ts index d7bb6361..6c71f9f7 100644 --- a/apps/claw-frontend/src/lib/i18n/locales/ar.ts +++ b/apps/claw-frontend/src/lib/i18n/locales/ar.ts @@ -389,6 +389,34 @@ export const ar: TranslationDictionary = { typePreference: 'تفضيل', typeInstruction: 'تعليمات', filterAllTypes: 'جميع الأنواع', + tabSaved: 'محفوظة', + tabSuggestions: 'الاقتراحات', + tabAudit: 'السجل', + searchPlaceholder: 'ابحث في الذاكرة...', + filterAllScopes: 'كل النطاقات', + filterAllSources: 'كل المصادر', + filterAllSensitivities: 'كل مستويات الحساسية', + scopeUser: 'شخصي', + scopeThread: 'هذه المحادثة', + scopeWorkspace: 'مساحة العمل', + scopeProject: 'المشروع', + sourceUserManual: 'تم الإنشاء يدويًا', + sourceAiExtracted: 'مستخرج من المحادثة', + sourceAutomation: 'تم تعلمه آليًا', + sourceImported: 'مستورد', + sensitivityNormal: 'عادية', + sensitivitySensitive: 'حساسة', + sensitivityRedacted: 'مخفية', + loadingSuggestions: 'جارٍ تحميل الاقتراحات...', + loadingAudit: 'جارٍ تحميل السجل...', + noSuggestions: 'لا توجد اقتراحات حاليًا', + noAudit: 'لا توجد سجلات بعد', + auditNoMemoryId: '(تم حذف الذاكرة)', + approveSuggestion: 'اعتماد', + rejectSuggestion: 'رفض', + rejectAndSuppress: 'رفض وكتم المشابهات', + suggestionConfidence: 'الثقة: {value}%', + suggestionReason: 'السبب: {value}', }, files: { title: 'الملفات', diff --git a/apps/claw-frontend/src/lib/i18n/locales/de.ts b/apps/claw-frontend/src/lib/i18n/locales/de.ts index cfa3215f..168de6aa 100644 --- a/apps/claw-frontend/src/lib/i18n/locales/de.ts +++ b/apps/claw-frontend/src/lib/i18n/locales/de.ts @@ -397,6 +397,34 @@ export const de: TranslationDictionary = { typePreference: 'Präferenz', typeInstruction: 'Anweisung', filterAllTypes: 'Alle Typen', + tabSaved: 'Gespeichert', + tabSuggestions: 'Vorschläge', + tabAudit: 'Audit', + searchPlaceholder: 'Erinnerungen suchen...', + filterAllScopes: 'Alle Bereiche', + filterAllSources: 'Alle Quellen', + filterAllSensitivities: 'Alle Sensitivitätsstufen', + scopeUser: 'Persönlich', + scopeThread: 'Dieses Gespräch', + scopeWorkspace: 'Arbeitsbereich', + scopeProject: 'Projekt', + sourceUserManual: 'Manuell erstellt', + sourceAiExtracted: 'Aus dem Chat extrahiert', + sourceAutomation: 'Durch Automation gelernt', + sourceImported: 'Importiert', + sensitivityNormal: 'Normal', + sensitivitySensitive: 'Sensibel', + sensitivityRedacted: 'Redigiert', + loadingSuggestions: 'Vorschläge werden geladen...', + loadingAudit: 'Audit wird geladen...', + noSuggestions: 'Derzeit keine Vorschläge', + noAudit: 'Noch keine Audit-Einträge', + auditNoMemoryId: '(Erinnerung gelöscht)', + approveSuggestion: 'Genehmigen', + rejectSuggestion: 'Ablehnen', + rejectAndSuppress: 'Ablehnen & ähnliche unterdrücken', + suggestionConfidence: 'Vertrauen: {value}%', + suggestionReason: 'Grund: {value}', }, files: { title: 'Dateien', @@ -2336,7 +2364,8 @@ export const de: TranslationDictionary = { fileViewer: { loading: 'Datei wird geladen…', error: 'Diese Datei konnte nicht geladen werden.', - unsupported: 'Für diesen Dateityp ist keine Vorschau verfügbar. Nutzen Sie Herunterladen, um sie lokal zu öffnen.', + unsupported: + 'Für diesen Dateityp ist keine Vorschau verfügbar. Nutzen Sie Herunterladen, um sie lokal zu öffnen.', download: 'Herunterladen', close: 'Schließen', }, diff --git a/apps/claw-frontend/src/lib/i18n/locales/en.ts b/apps/claw-frontend/src/lib/i18n/locales/en.ts index 4aea7b11..fb8cac4f 100644 --- a/apps/claw-frontend/src/lib/i18n/locales/en.ts +++ b/apps/claw-frontend/src/lib/i18n/locales/en.ts @@ -391,6 +391,34 @@ export const en: TranslationDictionary = { typePreference: 'Preference', typeInstruction: 'Instruction', filterAllTypes: 'All Types', + tabSaved: 'Saved', + tabSuggestions: 'Suggestions', + tabAudit: 'Audit', + searchPlaceholder: 'Search memories...', + filterAllScopes: 'All scopes', + filterAllSources: 'All sources', + filterAllSensitivities: 'All sensitivities', + scopeUser: 'Personal', + scopeThread: 'This conversation', + scopeWorkspace: 'Workspace', + scopeProject: 'Project', + sourceUserManual: 'Created manually', + sourceAiExtracted: 'Extracted from chat', + sourceAutomation: 'Learned by automation', + sourceImported: 'Imported', + sensitivityNormal: 'Normal', + sensitivitySensitive: 'Sensitive', + sensitivityRedacted: 'Redacted', + loadingSuggestions: 'Loading suggestions...', + loadingAudit: 'Loading audit...', + noSuggestions: 'No suggestions right now', + noAudit: 'No audit entries yet', + auditNoMemoryId: '(memory deleted)', + approveSuggestion: 'Approve', + rejectSuggestion: 'Reject', + rejectAndSuppress: 'Reject & suppress similar', + suggestionConfidence: 'Confidence: {value}%', + suggestionReason: 'Reason: {value}', }, files: { title: 'Files', diff --git a/apps/claw-frontend/src/lib/i18n/locales/es.ts b/apps/claw-frontend/src/lib/i18n/locales/es.ts index fcfd6eff..bfa01013 100644 --- a/apps/claw-frontend/src/lib/i18n/locales/es.ts +++ b/apps/claw-frontend/src/lib/i18n/locales/es.ts @@ -393,6 +393,34 @@ export const es: TranslationDictionary = { typePreference: 'Preferencia', typeInstruction: 'Instrucción', filterAllTypes: 'Todos los tipos', + tabSaved: 'Guardadas', + tabSuggestions: 'Sugerencias', + tabAudit: 'Auditoría', + searchPlaceholder: 'Buscar memorias...', + filterAllScopes: 'Todos los ámbitos', + filterAllSources: 'Todas las fuentes', + filterAllSensitivities: 'Todas las sensibilidades', + scopeUser: 'Personal', + scopeThread: 'Esta conversación', + scopeWorkspace: 'Espacio de trabajo', + scopeProject: 'Proyecto', + sourceUserManual: 'Creada manualmente', + sourceAiExtracted: 'Extraída del chat', + sourceAutomation: 'Aprendida por automatización', + sourceImported: 'Importada', + sensitivityNormal: 'Normal', + sensitivitySensitive: 'Sensible', + sensitivityRedacted: 'Redactada', + loadingSuggestions: 'Cargando sugerencias...', + loadingAudit: 'Cargando auditoría...', + noSuggestions: 'No hay sugerencias en este momento', + noAudit: 'Aún no hay entradas de auditoría', + auditNoMemoryId: '(memoria eliminada)', + approveSuggestion: 'Aprobar', + rejectSuggestion: 'Rechazar', + rejectAndSuppress: 'Rechazar y silenciar similares', + suggestionConfidence: 'Confianza: {value}%', + suggestionReason: 'Razón: {value}', }, files: { title: 'Archivos', @@ -2340,7 +2368,8 @@ export const es: TranslationDictionary = { fileViewer: { loading: 'Cargando archivo…', error: 'No se pudo cargar este archivo.', - unsupported: 'No hay vista previa disponible para este tipo de archivo. Usa Descargar para abrirlo localmente.', + unsupported: + 'No hay vista previa disponible para este tipo de archivo. Usa Descargar para abrirlo localmente.', download: 'Descargar', close: 'Cerrar', }, diff --git a/apps/claw-frontend/src/lib/i18n/locales/fr.ts b/apps/claw-frontend/src/lib/i18n/locales/fr.ts index 569ed996..0c8f29ec 100644 --- a/apps/claw-frontend/src/lib/i18n/locales/fr.ts +++ b/apps/claw-frontend/src/lib/i18n/locales/fr.ts @@ -396,6 +396,34 @@ export const fr: TranslationDictionary = { typePreference: 'Préférence', typeInstruction: 'Instruction', filterAllTypes: 'Tous les types', + tabSaved: 'Enregistrés', + tabSuggestions: 'Suggestions', + tabAudit: 'Audit', + searchPlaceholder: 'Rechercher des mémoires...', + filterAllScopes: 'Tous les périmètres', + filterAllSources: 'Toutes les sources', + filterAllSensitivities: 'Toutes les sensibilités', + scopeUser: 'Personnel', + scopeThread: 'Cette conversation', + scopeWorkspace: 'Espace de travail', + scopeProject: 'Projet', + sourceUserManual: 'Créée manuellement', + sourceAiExtracted: 'Extraite du chat', + sourceAutomation: 'Apprise par automatisation', + sourceImported: 'Importée', + sensitivityNormal: 'Normal', + sensitivitySensitive: 'Sensible', + sensitivityRedacted: 'Expurgée', + loadingSuggestions: 'Chargement des suggestions...', + loadingAudit: "Chargement de l'audit...", + noSuggestions: 'Aucune suggestion pour le moment', + noAudit: "Aucune entrée d'audit pour l'instant", + auditNoMemoryId: '(mémoire supprimée)', + approveSuggestion: 'Approuver', + rejectSuggestion: 'Rejeter', + rejectAndSuppress: 'Rejeter et masquer les similaires', + suggestionConfidence: 'Confiance : {value}%', + suggestionReason: 'Raison : {value}', }, files: { title: 'Fichiers', @@ -1479,11 +1507,11 @@ export const fr: TranslationDictionary = { 'Partagez ce connecteur avec d’autres utilisateurs. Le propriétaire a toujours un accès complet ; les bénéficiaires voient le niveau d’accès que vous choisissez.', loading: 'Chargement des accès…', error: 'Impossible de charger les accès.', - empty: 'Aucun accès pour l\'instant. Ce connecteur n\'est accessible qu\'à vous.', + empty: "Aucun accès pour l'instant. Ce connecteur n'est accessible qu'à vous.", granteeUserIdLabel: 'ID utilisateur du bénéficiaire', granteeUserIdPlaceholder: 'cmnkgdc2c00009w92riuztmmd', - accessLevelLabel: 'Niveau d\'accès', - grant: 'Accorder l\'accès', + accessLevelLabel: "Niveau d'accès", + grant: "Accorder l'accès", granting: 'Octroi en cours…', revoke: 'Révoquer', revoking: 'Révocation…', @@ -2290,10 +2318,10 @@ export const fr: TranslationDictionary = { page: { title: 'Modèles d’e-mail', description: - 'Points de départ réutilisables (objet + corps) pour les e-mails rédigés par l\'IA. Les {{placeholders}} sont remplis lors de l\'utilisation du modèle.', + "Points de départ réutilisables (objet + corps) pour les e-mails rédigés par l'IA. Les {{placeholders}} sont remplis lors de l'utilisation du modèle.", loading: 'Chargement des modèles…', error: 'Impossible de charger les modèles. Actualisez la page.', - empty: 'Aucun modèle pour l\'instant. Créez-en un pour commencer.', + empty: "Aucun modèle pour l'instant. Créez-en un pour commencer.", create: 'Nouveau modèle', edit: 'Modifier', delete: 'Supprimer', @@ -2347,7 +2375,8 @@ export const fr: TranslationDictionary = { fileViewer: { loading: 'Chargement du fichier…', error: 'Impossible de charger ce fichier.', - unsupported: 'Aperçu indisponible pour ce type de fichier. Utilisez Télécharger pour l\'ouvrir en local.', + unsupported: + "Aperçu indisponible pour ce type de fichier. Utilisez Télécharger pour l'ouvrir en local.", download: 'Télécharger', close: 'Fermer', }, diff --git a/apps/claw-frontend/src/lib/i18n/locales/hi.ts b/apps/claw-frontend/src/lib/i18n/locales/hi.ts index 1e9afcd9..85e07159 100644 --- a/apps/claw-frontend/src/lib/i18n/locales/hi.ts +++ b/apps/claw-frontend/src/lib/i18n/locales/hi.ts @@ -394,6 +394,34 @@ export const hi: TranslationDictionary = { typePreference: 'वरीयता', typeInstruction: 'निर्देश', filterAllTypes: 'सभी प्रकार', + tabSaved: 'सहेजे गए', + tabSuggestions: 'सुझाव', + tabAudit: 'ऑडिट', + searchPlaceholder: 'मेमोरी खोजें...', + filterAllScopes: 'सभी स्कोप', + filterAllSources: 'सभी स्रोत', + filterAllSensitivities: 'सभी संवेदनशीलताएँ', + scopeUser: 'व्यक्तिगत', + scopeThread: 'यह वार्तालाप', + scopeWorkspace: 'वर्कस्पेस', + scopeProject: 'परियोजना', + sourceUserManual: 'मैन्युअल रूप से बनाया गया', + sourceAiExtracted: 'चैट से निकाला गया', + sourceAutomation: 'ऑटोमेशन द्वारा सीखा गया', + sourceImported: 'आयातित', + sensitivityNormal: 'सामान्य', + sensitivitySensitive: 'संवेदनशील', + sensitivityRedacted: 'संशोधित', + loadingSuggestions: 'सुझाव लोड हो रहे हैं...', + loadingAudit: 'ऑडिट लोड हो रहा है...', + noSuggestions: 'अभी कोई सुझाव नहीं', + noAudit: 'अभी तक कोई ऑडिट प्रविष्टि नहीं', + auditNoMemoryId: '(मेमोरी हटा दी गई)', + approveSuggestion: 'स्वीकार करें', + rejectSuggestion: 'अस्वीकार करें', + rejectAndSuppress: 'अस्वीकार करें और समान दबाएँ', + suggestionConfidence: 'विश्वास: {value}%', + suggestionReason: 'कारण: {value}', }, files: { title: 'फ़ाइलें', @@ -2317,7 +2345,8 @@ export const hi: TranslationDictionary = { fileViewer: { loading: 'फ़ाइल लोड हो रही है…', error: 'यह फ़ाइल लोड नहीं हो सकी।', - unsupported: 'इस फ़ाइल प्रकार के लिए पूर्वावलोकन उपलब्ध नहीं है। इसे स्थानीय रूप से खोलने के लिए डाउनलोड का उपयोग करें।', + unsupported: + 'इस फ़ाइल प्रकार के लिए पूर्वावलोकन उपलब्ध नहीं है। इसे स्थानीय रूप से खोलने के लिए डाउनलोड का उपयोग करें।', download: 'डाउनलोड', close: 'बंद करें', }, diff --git a/apps/claw-frontend/src/lib/i18n/locales/it.ts b/apps/claw-frontend/src/lib/i18n/locales/it.ts index 6a06cbbd..dfe5de3d 100644 --- a/apps/claw-frontend/src/lib/i18n/locales/it.ts +++ b/apps/claw-frontend/src/lib/i18n/locales/it.ts @@ -397,6 +397,34 @@ export const it: TranslationDictionary = { typePreference: 'Preferenza', typeInstruction: 'Istruzione', filterAllTypes: 'Tutti i tipi', + tabSaved: 'Salvate', + tabSuggestions: 'Suggerimenti', + tabAudit: 'Audit', + searchPlaceholder: 'Cerca memorie...', + filterAllScopes: 'Tutti gli ambiti', + filterAllSources: 'Tutte le fonti', + filterAllSensitivities: 'Tutte le sensibilità', + scopeUser: 'Personale', + scopeThread: 'Questa conversazione', + scopeWorkspace: 'Spazio di lavoro', + scopeProject: 'Progetto', + sourceUserManual: 'Creata manualmente', + sourceAiExtracted: 'Estratta dalla chat', + sourceAutomation: "Appresa dall'automazione", + sourceImported: 'Importata', + sensitivityNormal: 'Normale', + sensitivitySensitive: 'Sensibile', + sensitivityRedacted: 'Redatta', + loadingSuggestions: 'Caricamento suggerimenti...', + loadingAudit: 'Caricamento audit...', + noSuggestions: 'Nessun suggerimento al momento', + noAudit: 'Ancora nessuna voce di audit', + auditNoMemoryId: '(memoria eliminata)', + approveSuggestion: 'Approva', + rejectSuggestion: 'Rifiuta', + rejectAndSuppress: 'Rifiuta e silenzia simili', + suggestionConfidence: 'Confidenza: {value}%', + suggestionReason: 'Motivo: {value}', }, files: { title: 'File', @@ -2275,7 +2303,7 @@ export const it: TranslationDictionary = { page: { title: 'Modelli e-mail', description: - 'Punti di partenza riutilizzabili (oggetto + corpo) per e-mail redatte dall\'IA. I {{placeholders}} vengono compilati all\'uso del modello.', + "Punti di partenza riutilizzabili (oggetto + corpo) per e-mail redatte dall'IA. I {{placeholders}} vengono compilati all'uso del modello.", loading: 'Caricamento modelli…', error: 'Impossibile caricare i modelli. Aggiorna la pagina.', empty: 'Ancora nessun modello. Creane uno per iniziare.', @@ -2332,7 +2360,8 @@ export const it: TranslationDictionary = { fileViewer: { loading: 'Caricamento file…', error: 'Impossibile caricare questo file.', - unsupported: 'Anteprima non disponibile per questo tipo di file. Usa Scarica per aprirlo in locale.', + unsupported: + 'Anteprima non disponibile per questo tipo di file. Usa Scarica per aprirlo in locale.', download: 'Scarica', close: 'Chiudi', }, diff --git a/apps/claw-frontend/src/lib/i18n/locales/pt.ts b/apps/claw-frontend/src/lib/i18n/locales/pt.ts index 38e9b168..712429e5 100644 --- a/apps/claw-frontend/src/lib/i18n/locales/pt.ts +++ b/apps/claw-frontend/src/lib/i18n/locales/pt.ts @@ -395,6 +395,34 @@ export const pt: TranslationDictionary = { typePreference: 'Preferência', typeInstruction: 'Instrução', filterAllTypes: 'Todos os tipos', + tabSaved: 'Salvas', + tabSuggestions: 'Sugestões', + tabAudit: 'Auditoria', + searchPlaceholder: 'Pesquisar memórias...', + filterAllScopes: 'Todos os escopos', + filterAllSources: 'Todas as fontes', + filterAllSensitivities: 'Todas as sensibilidades', + scopeUser: 'Pessoal', + scopeThread: 'Esta conversa', + scopeWorkspace: 'Espaço de trabalho', + scopeProject: 'Projeto', + sourceUserManual: 'Criada manualmente', + sourceAiExtracted: 'Extraída do chat', + sourceAutomation: 'Aprendida por automação', + sourceImported: 'Importada', + sensitivityNormal: 'Normal', + sensitivitySensitive: 'Sensível', + sensitivityRedacted: 'Censurada', + loadingSuggestions: 'Carregando sugestões...', + loadingAudit: 'Carregando auditoria...', + noSuggestions: 'Sem sugestões no momento', + noAudit: 'Sem entradas de auditoria ainda', + auditNoMemoryId: '(memória excluída)', + approveSuggestion: 'Aprovar', + rejectSuggestion: 'Rejeitar', + rejectAndSuppress: 'Rejeitar e suprimir semelhantes', + suggestionConfidence: 'Confiança: {value}%', + suggestionReason: 'Motivo: {value}', }, files: { title: 'Arquivos', @@ -2326,7 +2354,8 @@ export const pt: TranslationDictionary = { fileViewer: { loading: 'Carregando arquivo…', error: 'Não foi possível carregar este arquivo.', - unsupported: 'Pré-visualização indisponível para este tipo de arquivo. Use Baixar para abri-lo localmente.', + unsupported: + 'Pré-visualização indisponível para este tipo de arquivo. Use Baixar para abri-lo localmente.', download: 'Baixar', close: 'Fechar', }, diff --git a/apps/claw-frontend/src/lib/i18n/locales/ru.ts b/apps/claw-frontend/src/lib/i18n/locales/ru.ts index 9e9fbc15..3213fbc6 100644 --- a/apps/claw-frontend/src/lib/i18n/locales/ru.ts +++ b/apps/claw-frontend/src/lib/i18n/locales/ru.ts @@ -394,6 +394,34 @@ export const ru: TranslationDictionary = { typePreference: 'Предпочтение', typeInstruction: 'Инструкция', filterAllTypes: 'Все типы', + tabSaved: 'Сохранённые', + tabSuggestions: 'Предложения', + tabAudit: 'Аудит', + searchPlaceholder: 'Поиск воспоминаний...', + filterAllScopes: 'Все области', + filterAllSources: 'Все источники', + filterAllSensitivities: 'Все уровни чувствительности', + scopeUser: 'Личная', + scopeThread: 'Этот разговор', + scopeWorkspace: 'Рабочая область', + scopeProject: 'Проект', + sourceUserManual: 'Создано вручную', + sourceAiExtracted: 'Извлечено из чата', + sourceAutomation: 'Изучено автоматизацией', + sourceImported: 'Импортировано', + sensitivityNormal: 'Обычная', + sensitivitySensitive: 'Конфиденциальная', + sensitivityRedacted: 'Отредактировано', + loadingSuggestions: 'Загрузка предложений...', + loadingAudit: 'Загрузка аудита...', + noSuggestions: 'Сейчас нет предложений', + noAudit: 'Записей аудита пока нет', + auditNoMemoryId: '(память удалена)', + approveSuggestion: 'Принять', + rejectSuggestion: 'Отклонить', + rejectAndSuppress: 'Отклонить и подавить похожие', + suggestionConfidence: 'Уверенность: {value}%', + suggestionReason: 'Причина: {value}', }, files: { title: 'Файлы', @@ -2332,7 +2360,8 @@ export const ru: TranslationDictionary = { fileViewer: { loading: 'Загрузка файла…', error: 'Не удалось загрузить этот файл.', - unsupported: 'Предпросмотр недоступен для этого типа файла. Используйте «Скачать», чтобы открыть его локально.', + unsupported: + 'Предпросмотр недоступен для этого типа файла. Используйте «Скачать», чтобы открыть его локально.', download: 'Скачать', close: 'Закрыть', }, diff --git a/apps/claw-frontend/src/repositories/memory/memory.repository.ts b/apps/claw-frontend/src/repositories/memory/memory.repository.ts index cc194c06..9a3b2aad 100644 --- a/apps/claw-frontend/src/repositories/memory/memory.repository.ts +++ b/apps/claw-frontend/src/repositories/memory/memory.repository.ts @@ -1,15 +1,24 @@ -import { apiClient } from "@/services/shared/api-client"; +import { apiClient } from '@/services/shared/api-client'; import type { - MemoryRecord, + ApproveSuggestionRequest, + BulkApproveResult, CreateMemoryRequest, + MemoryAuditLog, + MemoryPreference, + MemoryRecord, + MemorySuggestion, + MemoryUsageEntry, + RejectSuggestionRequest, UpdateMemoryRequest, -} from "@/types"; + UpsertMemoryPreferenceRequest, +} from '@/types'; export const memoryRepository = { - async getMemories( - params?: Record, - ): Promise { - const response = await apiClient.get<{ data: MemoryRecord[]; meta: unknown }>("/memories", params); + async getMemories(params?: Record): Promise { + const response = await apiClient.get<{ data: MemoryRecord[]; meta: unknown }>( + '/memories', + params, + ); return response.data.data; }, @@ -19,29 +28,101 @@ export const memoryRepository = { }, async createMemory(data: CreateMemoryRequest): Promise { - const response = await apiClient.post("/memories", data); + const response = await apiClient.post('/memories', data); + return response.data; + }, + + async updateMemory(id: string, data: UpdateMemoryRequest): Promise { + const response = await apiClient.patch(`/memories/${id}`, data); + return response.data; + }, + + async deleteMemory(id: string, confirm: boolean): Promise { + const query = confirm ? '?confirm=FORGET' : ''; + await apiClient.delete(`/memories/${id}${query}`); + }, + + async toggleMemory(id: string): Promise { + const response = await apiClient.patch(`/memories/${id}/toggle`); + return response.data; + }, + + async searchMemories(query: string, limit = 10): Promise { + const response = await apiClient.post<{ data: MemoryRecord[]; meta: unknown }>( + '/memories/search', + { query, limit }, + ); + return response.data.data; + }, + + // === Suggestions === + async listSuggestions(params?: Record): Promise { + const response = await apiClient.get<{ data: MemorySuggestion[]; meta: unknown }>( + '/memory-suggestions', + params, + ); + return response.data.data; + }, + + async approveSuggestion(id: string, data: ApproveSuggestionRequest): Promise { + const response = await apiClient.post(`/memory-suggestions/${id}/approve`, data); return response.data; }, - async updateMemory( - id: string, - data: UpdateMemoryRequest, - ): Promise { - const response = await apiClient.patch( - `/memories/${id}`, + async rejectSuggestion(id: string, data: RejectSuggestionRequest): Promise { + const response = await apiClient.post( + `/memory-suggestions/${id}/reject`, data, ); return response.data; }, - async deleteMemory(id: string): Promise { - await apiClient.delete(`/memories/${id}`); + async dismissSuggestion(id: string): Promise { + const response = await apiClient.delete(`/memory-suggestions/${id}`); + return response.data; }, - async toggleMemory( - id: string, - ): Promise { - const response = await apiClient.patch(`/memories/${id}/toggle`); + async bulkApproveSuggestions(suggestionIds: string[]): Promise { + const response = await apiClient.post('/memory-suggestions/bulk-approve', { + suggestionIds, + }); + return response.data; + }, + + // === Audit === + async listAudit(limit = 100): Promise { + const response = await apiClient.get('/memory-audit', { + limit: String(limit), + }); + return response.data; + }, + + async listAuditForMemory(memoryId: string): Promise { + const response = await apiClient.get(`/memory-audit/${memoryId}`); + return response.data; + }, + + // === Usage === + async listUsageForMemory(memoryId: string): Promise { + const response = await apiClient.get(`/memory-usage/by-memory/${memoryId}`); + return response.data; + }, + + async listUsageForMessage(messageId: string): Promise { + const response = await apiClient.get( + `/memory-usage/by-message/${messageId}`, + ); + return response.data; + }, + + // === Preferences === + async getPreferences(): Promise { + const response = await apiClient.get('/memory-preferences'); + return response.data; + }, + + async upsertPreferences(data: UpsertMemoryPreferenceRequest): Promise { + const response = await apiClient.put('/memory-preferences', data); return response.data; }, }; diff --git a/apps/claw-frontend/src/repositories/shared/query-keys.ts b/apps/claw-frontend/src/repositories/shared/query-keys.ts index 62b90ef9..c2a62993 100644 --- a/apps/claw-frontend/src/repositories/shared/query-keys.ts +++ b/apps/claw-frontend/src/repositories/shared/query-keys.ts @@ -79,6 +79,13 @@ export const queryKeys = { lists: () => [...queryKeys.memory.all, 'list'] as const, list: (filters: Record) => [...queryKeys.memory.lists(), filters] as const, detail: (id: string) => [...queryKeys.memory.all, 'detail', id] as const, + search: (query: string) => [...queryKeys.memory.all, 'search', query] as const, + audit: (memoryId: string) => [...queryKeys.memory.all, 'audit', memoryId] as const, + auditAll: () => [...queryKeys.memory.all, 'audit', 'all'] as const, + usage: (memoryId: string) => [...queryKeys.memory.all, 'usage', memoryId] as const, + suggestions: (filters: Record) => + [...queryKeys.memory.all, 'suggestions', filters] as const, + preferences: () => [...queryKeys.memory.all, 'preferences'] as const, }, contextPacks: { all: ['contextPacks'] as const, diff --git a/apps/claw-frontend/src/types/component.types.ts b/apps/claw-frontend/src/types/component.types.ts index d30cc4b9..84fd1248 100644 --- a/apps/claw-frontend/src/types/component.types.ts +++ b/apps/claw-frontend/src/types/component.types.ts @@ -62,7 +62,14 @@ import type { ServerLogsTabProps, ServerLogStats, } from './log.types'; -import type { CreateMemoryRequest, MemoryRecord } from './memory.types'; +import type { + ApproveSuggestionRequest, + CreateMemoryRequest, + MemoryAuditLog, + MemoryRecord, + MemorySuggestion, + RejectSuggestionRequest, +} from './memory.types'; import type { ParallelModelResponse, ParallelModelTarget, @@ -561,6 +568,20 @@ export type MemoryFormProps = { memory?: MemoryRecord | null; }; +// === Memory V2 === +export type SuggestionsListProps = { + suggestions: MemorySuggestion[]; + isLoading: boolean; + isPending: boolean; + onApprove: (id: string, data?: ApproveSuggestionRequest) => void; + onReject: (id: string, data?: RejectSuggestionRequest) => void; +}; + +export type AuditListProps = { + entries: MemoryAuditLog[]; + isLoading: boolean; +}; + // ─── Observability component props ────────────────────────────────────────── export type StatCardProps = { diff --git a/apps/claw-frontend/src/types/i18n.types.ts b/apps/claw-frontend/src/types/i18n.types.ts index 81987330..3a689bc0 100644 --- a/apps/claw-frontend/src/types/i18n.types.ts +++ b/apps/claw-frontend/src/types/i18n.types.ts @@ -388,6 +388,35 @@ export type TranslationDictionary = { typePreference: string; typeInstruction: string; filterAllTypes: string; + // === Memory V2 === + tabSaved: string; + tabSuggestions: string; + tabAudit: string; + searchPlaceholder: string; + filterAllScopes: string; + filterAllSources: string; + filterAllSensitivities: string; + scopeUser: string; + scopeThread: string; + scopeWorkspace: string; + scopeProject: string; + sourceUserManual: string; + sourceAiExtracted: string; + sourceAutomation: string; + sourceImported: string; + sensitivityNormal: string; + sensitivitySensitive: string; + sensitivityRedacted: string; + loadingSuggestions: string; + loadingAudit: string; + noSuggestions: string; + noAudit: string; + auditNoMemoryId: string; + approveSuggestion: string; + rejectSuggestion: string; + rejectAndSuppress: string; + suggestionConfidence: string; + suggestionReason: string; }; files: { title: string; diff --git a/apps/claw-frontend/src/types/index.ts b/apps/claw-frontend/src/types/index.ts index 2bf84153..e0fec351 100644 --- a/apps/claw-frontend/src/types/index.ts +++ b/apps/claw-frontend/src/types/index.ts @@ -89,6 +89,14 @@ export type { UpdateMemoryParams, MemoryFormStateParams, MemoryFormStateReturn, + MemorySuggestion, + MemoryAuditLog, + MemoryUsageEntry, + MemoryPreference, + UpsertMemoryPreferenceRequest, + ApproveSuggestionRequest, + RejectSuggestionRequest, + BulkApproveResult, } from './memory.types'; export type { ContextPack, @@ -112,12 +120,7 @@ export type { DashboardPageResult, } from './dashboard.types'; export type { ServiceHealthResult, AggregatedHealth } from './health.types'; -export type { - ApiRequestConfig, - ApiResponse, - ApiError, - ApiClientRequestOptions, -} from './api.types'; +export type { ApiRequestConfig, ApiResponse, ApiError, ApiClientRequestOptions } from './api.types'; export type { AuthStoreState, AuthStoreActions, diff --git a/apps/claw-frontend/src/types/memory.types.ts b/apps/claw-frontend/src/types/memory.types.ts index 897f9354..01b830b6 100644 --- a/apps/claw-frontend/src/types/memory.types.ts +++ b/apps/claw-frontend/src/types/memory.types.ts @@ -1,4 +1,13 @@ -import type { MemoryFilterValue, MemoryType } from '@/enums'; +import type { + MemoryAuditAction, + MemoryFilterValue, + MemoryRetention, + MemoryScope, + MemorySensitivity, + MemorySource, + MemorySuggestionStatus, + MemoryType, +} from '@/enums'; import type { FormFieldErrors } from './component.types'; @@ -10,21 +19,138 @@ export type MemoryRecord = { sourceThreadId: string | null; sourceMessageId: string | null; isEnabled: boolean; + scope: MemoryScope; + scopeRef: string | null; + tags: string[]; + category: string | null; + priority: number; + confidence: number; + source: MemorySource; + sensitivity: MemorySensitivity; + retentionPolicy: MemoryRetention; + expiresAt: string | null; + pinned: boolean; + pausedUntil: string | null; + qualityScore: number; + useCount: number; + lastUsedAt: string | null; + provenanceJson: Record | null; createdAt: string; updatedAt: string; }; +export type MemorySuggestion = { + id: string; + userId: string; + type: MemoryType; + content: string; + sourceThreadId: string | null; + sourceMessageId: string | null; + confidence: number; + sensitivity: MemorySensitivity; + reason: string | null; + status: MemorySuggestionStatus; + decidedAt: string | null; + decidedBy: string | null; + resultingMemoryId: string | null; + createdAt: string; +}; + +export type MemoryAuditLog = { + id: string; + memoryId: string | null; + userId: string; + action: MemoryAuditAction; + actor: string; + details: Record | null; + createdAt: string; +}; + +export type MemoryUsageEntry = { + id: string; + memoryId: string; + userId: string; + threadId: string; + messageId: string; + score: number; + reason: string | null; + createdAt: string; +}; + +export type MemoryPreference = { + userId: string; + pausedAll: boolean; + autoApproveThreshold: number; + defaultRetention: MemoryRetention; + defaultExpiresInDays: number | null; + redactByDefault: boolean; + updatedAt: string; +}; + +export type UpsertMemoryPreferenceRequest = { + pausedAll?: boolean; + autoApproveThreshold?: number; + defaultRetention?: MemoryRetention; + defaultExpiresInDays?: number | null; + redactByDefault?: boolean; +}; + +export type ApproveSuggestionRequest = { + editedContent?: string; + scope?: MemoryScope; + scopeRef?: string; + retentionPolicy?: MemoryRetention; + expiresAt?: string; +}; + +export type RejectSuggestionRequest = { + reason?: string; + suppressSimilar?: boolean; +}; + +export type BulkApproveSkippedEntry = { + suggestionId: string; + reason: string; +}; + +export type BulkApproveResult = { + approved: string[]; + skipped: BulkApproveSkippedEntry[]; +}; + export type CreateMemoryRequest = { type: MemoryType; content: string; sourceThreadId?: string; sourceMessageId?: string; + scope?: MemoryScope; + scopeRef?: string; + tags?: string[]; + category?: string; + priority?: number; + confidence?: number; + source?: MemorySource; + sensitivity?: MemorySensitivity; + retentionPolicy?: MemoryRetention; + expiresAt?: string; + pinned?: boolean; + provenanceJson?: Record; }; export type UpdateMemoryRequest = { content?: string; type?: MemoryType; isEnabled?: boolean; + scope?: MemoryScope; + scopeRef?: string | null; + tags?: string[]; + category?: string | null; + priority?: number; + retentionPolicy?: MemoryRetention; + expiresAt?: string | null; + sensitivity?: MemorySensitivity; + pinned?: boolean; + pausedUntil?: string | null; }; export type MemoryFilterType = MemoryType | MemoryFilterValue.ALL; diff --git a/apps/claw-memory-service/CLAUDE.md b/apps/claw-memory-service/CLAUDE.md index b6ee25e5..b870e86e 100644 --- a/apps/claw-memory-service/CLAUDE.md +++ b/apps/claw-memory-service/CLAUDE.md @@ -18,9 +18,39 @@ This is the Memory microservice for the Claw platform. It owns memory records an ## Tables Owned -- `memory_records` -- `context_packs` -- `context_pack_items` +- `memory_records` (V2: + scope/scopeRef/tags/category/priority/confidence/source/sensitivity/retentionPolicy/expiresAt/pinned/pausedUntil/qualityScore/useCount/lastUsedAt/provenanceJson) +- `memory_suggestions` (V2 — suggestion queue gated by user approval / auto-approve threshold) +- `memory_usages` (V2 — per-message retrieval telemetry) +- `memory_audit_logs` (V2 — survives memory row deletion; one row per CRUD/use/approve/reject action) +- `memory_preferences` (V2 — per-user pausedAll, autoApproveThreshold, defaultRetention, defaultExpiresInDays, redactByDefault) +- `context_packs` (V2: + scope enum/scopeRef/legacyScope/tags/visibility/isEnabled/pausedUntil/pinned/color/icon/version/templateId/ownerUserId/useCount/lastUsedAt/qualityScore) +- `context_pack_items` (V2: + itemType enum/legacyType/url/memoryRefId/isEnabled/pinned/tokenCountEstimate/compressedSummary) +- `context_pack_versions` (V2 — immutable history of pack edits; pruned at CONTEXT_VERSION_RETENTION_COUNT) +- `context_pack_usages` (V2 — per-message pack retrieval log) +- `context_pack_attachments` (V2 — many-to-many between packs and scope+scopeRef) +- `context_pack_templates` (V2 — system + user-created templates) + +## V2 Modules Layout + +``` +src/modules/ + memory/ # Memory CRUD + retrieval + extraction (existing, extended) + memory-suggestions/ # NEW: suggestion queue (approve / reject / bulk / dismiss) + memory-preferences/ # NEW: per-user pausedAll / autoApproveThreshold / defaults + memory-audit/ # NEW: per-memory + per-user audit timeline + memory-usage/ # NEW: usage telemetry queries + context-packs/ # Existing, extended for scopes / visibility / attachments / versions + embeddings/ # Existing — backs workspace + (future) pack/memory embeddings +``` + +The retrieval endpoint `POST /internal/memories/retrieve` is the canonical entry point for chat-service. It returns a `RetrievalBundle` (shared-types) with scope-filtered + sensitivity-sanitized memories and pack items. `POST /internal/memories/record-usage` writes the corresponding `memory_usages` rows and emits `MEMORY_USED` events. + +## V2 Sensitivity Rules (non-negotiable) + +- `MemorySensitivityManager.classify(content)` runs on EVERY new memory before persistence (manual create and auto-extract). +- Hits for `aws_access_key`, `aws_secret_key`, `private_key_block`, `jwt`, `ssn_us`, `credit_card`, `google_api_key`, `github_token`, `openai_key` → verdict `REDACTED`, content is masked to `XX*****YYYY` before write. +- Soft hints (`password`, `salary`, `medical`, …) → verdict `SENSITIVE` with confidence < 1. +- Auto-approve from the suggestion queue ONLY fires for verdict `NORMAL` AND confidence ≥ `memory_preferences.autoApproveThreshold` (default 0.85). ## All Standard Backend Rules Apply diff --git a/apps/claw-memory-service/prisma/migrations/20260524000000_memory_context_v2/migration.sql b/apps/claw-memory-service/prisma/migrations/20260524000000_memory_context_v2/migration.sql new file mode 100644 index 00000000..65aa844f --- /dev/null +++ b/apps/claw-memory-service/prisma/migrations/20260524000000_memory_context_v2/migration.sql @@ -0,0 +1,220 @@ +-- Memory + Context Flagship V2 — additive migration. +-- Adds enums, columns, and tables required for the V2 control center +-- (suggestion queue, scopes, sensitivity, retention, audit log, usage, +-- preference upsert, context-pack versions/usages/attachments/templates). + +-- === New enums === +CREATE TYPE "MemoryScope" AS ENUM ('USER', 'THREAD', 'WORKSPACE', 'PROJECT'); +CREATE TYPE "MemorySource" AS ENUM ('USER_MANUAL', 'AI_EXTRACTED', 'AUTOMATION_LEARNING', 'IMPORTED'); +CREATE TYPE "MemorySensitivity" AS ENUM ('NORMAL', 'SENSITIVE', 'REDACTED'); +CREATE TYPE "MemoryRetention" AS ENUM ('PERMANENT', 'EXPIRING', 'AUTO_DECAY'); +CREATE TYPE "MemorySuggestionStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED', 'AUTO_APPROVED', 'DISMISSED', 'EXPIRED'); +CREATE TYPE "MemoryAuditAction" AS ENUM ('CREATED', 'UPDATED', 'DELETED', 'USED', 'APPROVED', 'REJECTED', 'TOGGLED', 'PAUSED', 'RESUMED', 'REDACTED', 'IMPORTED', 'EXPORTED'); +CREATE TYPE "ContextPackScope" AS ENUM ('USER', 'WORKSPACE', 'PROJECT', 'THREAD'); +CREATE TYPE "ContextPackItemType" AS ENUM ('TEXT', 'FILE', 'URL', 'MARKDOWN', 'SNIPPET', 'MEMORY_REF'); +CREATE TYPE "ContextPackVisibility" AS ENUM ('PRIVATE', 'WORKSPACE', 'PUBLIC'); + +-- === MemoryRecord — additive columns === +ALTER TABLE "memory_records" + ADD COLUMN "scope" "MemoryScope" NOT NULL DEFAULT 'USER', + ADD COLUMN "scope_ref" TEXT, + ADD COLUMN "tags" TEXT[] NOT NULL DEFAULT '{}', + ADD COLUMN "category" TEXT, + ADD COLUMN "priority" INTEGER NOT NULL DEFAULT 50, + ADD COLUMN "confidence" DOUBLE PRECISION NOT NULL DEFAULT 1.0, + ADD COLUMN "source" "MemorySource" NOT NULL DEFAULT 'USER_MANUAL', + ADD COLUMN "sensitivity" "MemorySensitivity" NOT NULL DEFAULT 'NORMAL', + ADD COLUMN "retention_policy" "MemoryRetention" NOT NULL DEFAULT 'PERMANENT', + ADD COLUMN "expires_at" TIMESTAMP(3), + ADD COLUMN "pinned" BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN "paused_until" TIMESTAMP(3), + ADD COLUMN "quality_score" DOUBLE PRECISION NOT NULL DEFAULT 0.5, + ADD COLUMN "use_count" INTEGER NOT NULL DEFAULT 0, + ADD COLUMN "last_used_at" TIMESTAMP(3), + ADD COLUMN "provenance_json" JSONB; + +CREATE INDEX "memory_records_scope_scope_ref_idx" ON "memory_records"("scope", "scope_ref"); +CREATE INDEX "memory_records_userId_scope_isEnabled_updatedAt_idx" ON "memory_records"("user_id", "scope", "is_enabled", "updated_at"); +CREATE INDEX "memory_records_expires_at_idx" ON "memory_records"("expires_at"); + +-- === MemorySuggestion === +CREATE TABLE "memory_suggestions" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "type" "MemoryType" NOT NULL, + "content" TEXT NOT NULL, + "source_thread_id" TEXT, + "source_message_id" TEXT, + "confidence" DOUBLE PRECISION NOT NULL, + "sensitivity" "MemorySensitivity" NOT NULL DEFAULT 'NORMAL', + "reason" TEXT, + "status" "MemorySuggestionStatus" NOT NULL DEFAULT 'PENDING', + "decided_at" TIMESTAMP(3), + "decided_by" TEXT, + "resulting_memory_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "memory_suggestions_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "memory_suggestions_userId_status_idx" ON "memory_suggestions"("user_id", "status"); +CREATE INDEX "memory_suggestions_created_at_idx" ON "memory_suggestions"("created_at"); + +-- === MemoryUsage === +CREATE TABLE "memory_usages" ( + "id" TEXT NOT NULL, + "memory_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "thread_id" TEXT NOT NULL, + "message_id" TEXT NOT NULL, + "score" DOUBLE PRECISION NOT NULL, + "reason" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "memory_usages_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "memory_usages_memory_id_idx" ON "memory_usages"("memory_id"); +CREATE INDEX "memory_usages_userId_createdAt_idx" ON "memory_usages"("user_id", "created_at"); +CREATE INDEX "memory_usages_thread_id_idx" ON "memory_usages"("thread_id"); +CREATE INDEX "memory_usages_message_id_idx" ON "memory_usages"("message_id"); + +-- === MemoryAuditLog === +CREATE TABLE "memory_audit_logs" ( + "id" TEXT NOT NULL, + "memory_id" TEXT, + "user_id" TEXT NOT NULL, + "action" "MemoryAuditAction" NOT NULL, + "actor" TEXT NOT NULL, + "details" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "memory_audit_logs_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "memory_audit_logs_memory_id_idx" ON "memory_audit_logs"("memory_id"); +CREATE INDEX "memory_audit_logs_userId_createdAt_idx" ON "memory_audit_logs"("user_id", "created_at"); + +-- === MemoryPreference === +CREATE TABLE "memory_preferences" ( + "user_id" TEXT NOT NULL, + "paused_all" BOOLEAN NOT NULL DEFAULT false, + "auto_approve_threshold" DOUBLE PRECISION NOT NULL DEFAULT 0.85, + "default_retention" "MemoryRetention" NOT NULL DEFAULT 'PERMANENT', + "default_expires_in_days" INTEGER, + "redact_by_default" BOOLEAN NOT NULL DEFAULT true, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "memory_preferences_pkey" PRIMARY KEY ("user_id") +); + +-- === ContextPack — preserve legacy "scope" column first === +ALTER TABLE "context_packs" RENAME COLUMN "scope" TO "legacy_scope"; + +ALTER TABLE "context_packs" + ADD COLUMN "scope" "ContextPackScope" NOT NULL DEFAULT 'USER', + ADD COLUMN "scope_ref" TEXT, + ADD COLUMN "tags" TEXT[] NOT NULL DEFAULT '{}', + ADD COLUMN "visibility" "ContextPackVisibility" NOT NULL DEFAULT 'PRIVATE', + ADD COLUMN "is_enabled" BOOLEAN NOT NULL DEFAULT true, + ADD COLUMN "paused_until" TIMESTAMP(3), + ADD COLUMN "pinned" BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN "color" TEXT, + ADD COLUMN "icon" TEXT, + ADD COLUMN "version" INTEGER NOT NULL DEFAULT 1, + ADD COLUMN "template_id" TEXT, + ADD COLUMN "owner_user_id" TEXT, + ADD COLUMN "use_count" INTEGER NOT NULL DEFAULT 0, + ADD COLUMN "last_used_at" TIMESTAMP(3), + ADD COLUMN "quality_score" DOUBLE PRECISION NOT NULL DEFAULT 0.5; + +-- Default ownerUserId to userId (then enforce NOT NULL) +UPDATE "context_packs" SET "owner_user_id" = "user_id" WHERE "owner_user_id" IS NULL; +ALTER TABLE "context_packs" ALTER COLUMN "owner_user_id" SET NOT NULL; + +CREATE INDEX "context_packs_scope_scope_ref_idx" ON "context_packs"("scope", "scope_ref"); +CREATE INDEX "context_packs_visibility_idx" ON "context_packs"("visibility"); + +-- === ContextPackItem — additive columns + enum migration === +ALTER TABLE "context_pack_items" + ADD COLUMN "item_type" "ContextPackItemType" NOT NULL DEFAULT 'TEXT', + ADD COLUMN "url" TEXT, + ADD COLUMN "memory_ref_id" TEXT, + ADD COLUMN "is_enabled" BOOLEAN NOT NULL DEFAULT true, + ADD COLUMN "pinned" BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN "token_count_estimate" INTEGER NOT NULL DEFAULT 0, + ADD COLUMN "compressed_summary" TEXT, + ADD COLUMN "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- Preserve legacy free-text type column under "legacy_type" +ALTER TABLE "context_pack_items" RENAME COLUMN "type" TO "legacy_type"; + +-- Map known legacy string values to the new enum (defensive — best-effort). +UPDATE "context_pack_items" SET "item_type" = 'FILE' WHERE "legacy_type" ILIKE 'file%'; +UPDATE "context_pack_items" SET "item_type" = 'URL' WHERE "legacy_type" ILIKE 'url%'; +UPDATE "context_pack_items" SET "item_type" = 'MARKDOWN' WHERE "legacy_type" ILIKE 'markdown%'; +UPDATE "context_pack_items" SET "item_type" = 'SNIPPET' WHERE "legacy_type" ILIKE 'snippet%' OR "legacy_type" ILIKE 'code%'; +UPDATE "context_pack_items" SET "item_type" = 'TEXT' WHERE "legacy_type" IS NULL OR "legacy_type" ILIKE 'text%' OR "legacy_type" ILIKE 'instruction%' OR "legacy_type" = ''; + +CREATE INDEX "context_pack_items_is_enabled_idx" ON "context_pack_items"("is_enabled"); + +-- === ContextPackVersion === +CREATE TABLE "context_pack_versions" ( + "id" TEXT NOT NULL, + "context_pack_id" TEXT NOT NULL, + "version" INTEGER NOT NULL, + "payload_json" JSONB NOT NULL, + "summary" TEXT, + "changed_by" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "context_pack_versions_pkey" PRIMARY KEY ("id"), + CONSTRAINT "context_pack_versions_contextPackId_fkey" + FOREIGN KEY ("context_pack_id") REFERENCES "context_packs"("id") ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE UNIQUE INDEX "context_pack_versions_packId_version_unique" + ON "context_pack_versions"("context_pack_id", "version"); +CREATE INDEX "context_pack_versions_packId_createdAt_idx" + ON "context_pack_versions"("context_pack_id", "created_at"); + +-- === ContextPackUsage === +CREATE TABLE "context_pack_usages" ( + "id" TEXT NOT NULL, + "context_pack_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "thread_id" TEXT NOT NULL, + "message_id" TEXT NOT NULL, + "item_ids_used" TEXT[] NOT NULL DEFAULT '{}', + "score" DOUBLE PRECISION, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "context_pack_usages_pkey" PRIMARY KEY ("id"), + CONSTRAINT "context_pack_usages_contextPackId_fkey" + FOREIGN KEY ("context_pack_id") REFERENCES "context_packs"("id") ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE INDEX "context_pack_usages_packId_createdAt_idx" + ON "context_pack_usages"("context_pack_id", "created_at"); +CREATE INDEX "context_pack_usages_thread_id_idx" ON "context_pack_usages"("thread_id"); + +-- === ContextPackAttachment === +CREATE TABLE "context_pack_attachments" ( + "id" TEXT NOT NULL, + "context_pack_id" TEXT NOT NULL, + "scope" "ContextPackScope" NOT NULL, + "scope_ref" TEXT NOT NULL, + "attached_by" TEXT NOT NULL, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "attached_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "context_pack_attachments_pkey" PRIMARY KEY ("id"), + CONSTRAINT "context_pack_attachments_contextPackId_fkey" + FOREIGN KEY ("context_pack_id") REFERENCES "context_packs"("id") ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE UNIQUE INDEX "context_pack_attachments_pack_scope_ref_unique" + ON "context_pack_attachments"("context_pack_id", "scope", "scope_ref"); +CREATE INDEX "context_pack_attachments_scope_scope_ref_idx" + ON "context_pack_attachments"("scope", "scope_ref"); + +-- === ContextPackTemplate === +CREATE TABLE "context_pack_templates" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "category" TEXT NOT NULL, + "is_system" BOOLEAN NOT NULL DEFAULT false, + "payload_json" JSONB NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "context_pack_templates_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "context_pack_templates_category_idx" ON "context_pack_templates"("category"); +CREATE INDEX "context_pack_templates_isSystem_idx" ON "context_pack_templates"("is_system"); diff --git a/apps/claw-memory-service/prisma/schema.prisma b/apps/claw-memory-service/prisma/schema.prisma index 2cec222d..ab945e0c 100644 --- a/apps/claw-memory-service/prisma/schema.prisma +++ b/apps/claw-memory-service/prisma/schema.prisma @@ -17,6 +17,78 @@ enum MemoryType { INSTRUCTION } +enum MemoryScope { + USER + THREAD + WORKSPACE + PROJECT +} + +enum MemorySource { + USER_MANUAL + AI_EXTRACTED + AUTOMATION_LEARNING + IMPORTED +} + +enum MemorySensitivity { + NORMAL + SENSITIVE + REDACTED +} + +enum MemoryRetention { + PERMANENT + EXPIRING + AUTO_DECAY +} + +enum MemorySuggestionStatus { + PENDING + APPROVED + REJECTED + AUTO_APPROVED + DISMISSED + EXPIRED +} + +enum MemoryAuditAction { + CREATED + UPDATED + DELETED + USED + APPROVED + REJECTED + TOGGLED + PAUSED + RESUMED + REDACTED + IMPORTED + EXPORTED +} + +enum ContextPackScope { + USER + WORKSPACE + PROJECT + THREAD +} + +enum ContextPackItemType { + TEXT + FILE + URL + MARKDOWN + SNIPPET + MEMORY_REF +} + +enum ContextPackVisibility { + PRIVATE + WORKSPACE + PUBLIC +} + model MemoryRecord { id String @id @default(cuid()) userId String @map("user_id") @@ -25,46 +97,223 @@ model MemoryRecord { sourceThreadId String? @map("source_thread_id") sourceMessageId String? @map("source_message_id") isEnabled Boolean @default(true) @map("is_enabled") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + // === Memory V2 additions === + scope MemoryScope @default(USER) + scopeRef String? @map("scope_ref") + tags String[] @default([]) + category String? + priority Int @default(50) + confidence Float @default(1.0) + source MemorySource @default(USER_MANUAL) + sensitivity MemorySensitivity @default(NORMAL) + retentionPolicy MemoryRetention @default(PERMANENT) @map("retention_policy") + expiresAt DateTime? @map("expires_at") + pinned Boolean @default(false) + pausedUntil DateTime? @map("paused_until") + qualityScore Float @default(0.5) @map("quality_score") + useCount Int @default(0) @map("use_count") + lastUsedAt DateTime? @map("last_used_at") + provenanceJson Json? @map("provenance_json") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") @@index([userId]) @@index([type]) @@index([isEnabled]) + @@index([scope, scopeRef]) + @@index([userId, scope, isEnabled, updatedAt]) + @@index([expiresAt]) @@map("memory_records") } +model MemorySuggestion { + id String @id @default(cuid()) + userId String @map("user_id") + type MemoryType + content String + sourceThreadId String? @map("source_thread_id") + sourceMessageId String? @map("source_message_id") + confidence Float + sensitivity MemorySensitivity @default(NORMAL) + reason String? + status MemorySuggestionStatus @default(PENDING) + decidedAt DateTime? @map("decided_at") + decidedBy String? @map("decided_by") + resultingMemoryId String? @map("resulting_memory_id") + createdAt DateTime @default(now()) @map("created_at") + + @@index([userId, status]) + @@index([createdAt]) + @@map("memory_suggestions") +} + +model MemoryUsage { + id String @id @default(cuid()) + memoryId String @map("memory_id") + userId String @map("user_id") + threadId String @map("thread_id") + messageId String @map("message_id") + score Float + reason String? + createdAt DateTime @default(now()) @map("created_at") + + @@index([memoryId]) + @@index([userId, createdAt]) + @@index([threadId]) + @@index([messageId]) + @@map("memory_usages") +} + +model MemoryAuditLog { + id String @id @default(cuid()) + memoryId String? @map("memory_id") + userId String @map("user_id") + action MemoryAuditAction + actor String + details Json? + createdAt DateTime @default(now()) @map("created_at") + + @@index([memoryId]) + @@index([userId, createdAt]) + @@map("memory_audit_logs") +} + +model MemoryPreference { + userId String @id @map("user_id") + pausedAll Boolean @default(false) @map("paused_all") + autoApproveThreshold Float @default(0.85) @map("auto_approve_threshold") + defaultRetention MemoryRetention @default(PERMANENT) @map("default_retention") + defaultExpiresInDays Int? @map("default_expires_in_days") + redactByDefault Boolean @default(true) @map("redact_by_default") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("memory_preferences") +} + model ContextPack { - id String @id @default(cuid()) - userId String @map("user_id") - name String - description String? - scope String? - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id String @id @default(cuid()) + userId String @map("user_id") + name String + description String? + // === Context V2 additions === + scope ContextPackScope @default(USER) + scopeRef String? @map("scope_ref") + legacyScope String? @map("legacy_scope") + tags String[] @default([]) + visibility ContextPackVisibility @default(PRIVATE) + isEnabled Boolean @default(true) @map("is_enabled") + pausedUntil DateTime? @map("paused_until") + pinned Boolean @default(false) + color String? + icon String? + version Int @default(1) + templateId String? @map("template_id") + ownerUserId String @map("owner_user_id") + useCount Int @default(0) @map("use_count") + lastUsedAt DateTime? @map("last_used_at") + qualityScore Float @default(0.5) @map("quality_score") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") - items ContextPackItem[] + items ContextPackItem[] + versions ContextPackVersion[] + usages ContextPackUsage[] + attachments ContextPackAttachment[] @@index([userId]) + @@index([scope, scopeRef]) + @@index([visibility]) @@map("context_packs") } model ContextPackItem { - id String @id @default(cuid()) - contextPackId String @map("context_pack_id") - type String - content String? - fileId String? @map("file_id") - sortOrder Int @default(0) @map("sort_order") - createdAt DateTime @default(now()) @map("created_at") + id String @id @default(cuid()) + contextPackId String @map("context_pack_id") + itemType ContextPackItemType @default(TEXT) @map("item_type") + legacyType String? @map("legacy_type") + content String? + fileId String? @map("file_id") + url String? + memoryRefId String? @map("memory_ref_id") + sortOrder Int @default(0) @map("sort_order") + isEnabled Boolean @default(true) @map("is_enabled") + pinned Boolean @default(false) + tokenCountEstimate Int @default(0) @map("token_count_estimate") + compressedSummary String? @map("compressed_summary") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") contextPack ContextPack @relation(fields: [contextPackId], references: [id], onDelete: Cascade) @@index([contextPackId]) @@index([sortOrder]) + @@index([isEnabled]) @@map("context_pack_items") } +model ContextPackVersion { + id String @id @default(cuid()) + contextPackId String @map("context_pack_id") + version Int + payloadJson Json @map("payload_json") + summary String? + changedBy String @map("changed_by") + createdAt DateTime @default(now()) @map("created_at") + + contextPack ContextPack @relation(fields: [contextPackId], references: [id], onDelete: Cascade) + + @@unique([contextPackId, version]) + @@index([contextPackId, createdAt]) + @@map("context_pack_versions") +} + +model ContextPackUsage { + id String @id @default(cuid()) + contextPackId String @map("context_pack_id") + userId String @map("user_id") + threadId String @map("thread_id") + messageId String @map("message_id") + itemIdsUsed String[] @map("item_ids_used") + score Float? + createdAt DateTime @default(now()) @map("created_at") + + contextPack ContextPack @relation(fields: [contextPackId], references: [id], onDelete: Cascade) + + @@index([contextPackId, createdAt]) + @@index([threadId]) + @@map("context_pack_usages") +} + +model ContextPackAttachment { + id String @id @default(cuid()) + contextPackId String @map("context_pack_id") + scope ContextPackScope + scopeRef String @map("scope_ref") + attachedBy String @map("attached_by") + isActive Boolean @default(true) @map("is_active") + attachedAt DateTime @default(now()) @map("attached_at") + + contextPack ContextPack @relation(fields: [contextPackId], references: [id], onDelete: Cascade) + + @@unique([contextPackId, scope, scopeRef]) + @@index([scope, scopeRef]) + @@map("context_pack_attachments") +} + +model ContextPackTemplate { + id String @id @default(cuid()) + name String + description String? + category String + isSystem Boolean @default(false) @map("is_system") + payloadJson Json @map("payload_json") + createdAt DateTime @default(now()) @map("created_at") + + @@index([category]) + @@index([isSystem]) + @@map("context_pack_templates") +} + // Stream 30 — Unified inbox + cross-provider semantic search. // One embedding row per (workspaceObjectId, contentHash). Cosine search // powered by pgvector ivfflat index on `embedding`. diff --git a/apps/claw-memory-service/src/common/constants/index.ts b/apps/claw-memory-service/src/common/constants/index.ts index 1a6decc7..8885c098 100644 --- a/apps/claw-memory-service/src/common/constants/index.ts +++ b/apps/claw-memory-service/src/common/constants/index.ts @@ -1,3 +1,18 @@ export { JWT_ALGORITHM } from './jwt.constants'; export { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from './pagination.constants'; -export { VALID_MEMORY_TYPES, extractionResultSchema, EXTRACTION_PROMPT } from './extraction.constants'; +export { + VALID_MEMORY_TYPES, + extractionResultSchema, + EXTRACTION_PROMPT, +} from './extraction.constants'; +export { + SENSITIVITY_PRE_FILTER_PATTERNS, + SENSITIVITY_SOFT_HINTS, +} from './memory-sensitivity.constants'; +export { + DEFAULT_AUTO_APPROVE_THRESHOLD, + DEFAULT_SEMANTIC_BUDGET_MEMORY, + DEFAULT_SEMANTIC_BUDGET_CONTEXT, + MEMORY_RETRIEVAL_MAX, + CONTEXT_RETRIEVAL_MAX, +} from './memory-retrieval.constants'; diff --git a/apps/claw-memory-service/src/common/constants/memory-retrieval.constants.ts b/apps/claw-memory-service/src/common/constants/memory-retrieval.constants.ts new file mode 100644 index 00000000..de6d9c5d --- /dev/null +++ b/apps/claw-memory-service/src/common/constants/memory-retrieval.constants.ts @@ -0,0 +1,5 @@ +export const DEFAULT_AUTO_APPROVE_THRESHOLD = 0.85; +export const DEFAULT_SEMANTIC_BUDGET_MEMORY = 5; +export const DEFAULT_SEMANTIC_BUDGET_CONTEXT = 12; +export const MEMORY_RETRIEVAL_MAX = 50; +export const CONTEXT_RETRIEVAL_MAX = 100; diff --git a/apps/claw-memory-service/src/common/constants/memory-sensitivity.constants.ts b/apps/claw-memory-service/src/common/constants/memory-sensitivity.constants.ts new file mode 100644 index 00000000..63e5661b --- /dev/null +++ b/apps/claw-memory-service/src/common/constants/memory-sensitivity.constants.ts @@ -0,0 +1,38 @@ +/** + * Static patterns used by MemorySensitivityManager. + * Loosely modeled on common DLP detection rule sets; intentionally conservative + * (any false positive surfaces as REDACTED for the user to override). + */ +export const SENSITIVITY_PRE_FILTER_PATTERNS: ReadonlyArray<{ name: string; pattern: RegExp }> = [ + { name: 'aws_access_key', pattern: /\bAKIA[0-9A-Z]{16}\b/g }, + { name: 'aws_secret_key', pattern: /\b[0-9a-zA-Z/+]{40}\b/g }, + { + name: 'private_key_block', + pattern: /-----BEGIN (?:RSA|EC|OPENSSH|DSA|PGP)?\s?PRIVATE KEY-----/g, + }, + { name: 'jwt', pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g }, + { name: 'ssn_us', pattern: /\b\d{3}-\d{2}-\d{4}\b/g }, + { name: 'credit_card', pattern: /\b(?:\d[ -]?){13,19}\b/g }, + { name: 'google_api_key', pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g }, + { name: 'github_token', pattern: /\bgh[pousr]_[0-9A-Za-z]{30,}\b/g }, + // Bound the upper repetition to avoid ReDoS (security/detect-unsafe-regex). + { name: 'openai_key', pattern: /\bsk-[0-9A-Za-z]{20,256}\b/g }, +]; + +/** + * Soft hints — markers that probably indicate sensitive content but are not + * themselves a leak. Used to upgrade verdict to SENSITIVE (not REDACTED). + */ +export const SENSITIVITY_SOFT_HINTS: readonly string[] = [ + 'password', + 'passphrase', + 'secret', + 'credit card', + 'ssn', + 'social security', + 'medical', + 'diagnosis', + 'prescription', + 'salary', + 'compensation', +]; diff --git a/apps/claw-memory-service/src/common/enums/context-pack-item-type.enum.ts b/apps/claw-memory-service/src/common/enums/context-pack-item-type.enum.ts new file mode 100644 index 00000000..23593d2b --- /dev/null +++ b/apps/claw-memory-service/src/common/enums/context-pack-item-type.enum.ts @@ -0,0 +1 @@ +export { ContextPackItemType } from '@claw/shared-types'; diff --git a/apps/claw-memory-service/src/common/enums/context-pack-scope.enum.ts b/apps/claw-memory-service/src/common/enums/context-pack-scope.enum.ts new file mode 100644 index 00000000..7fc9f5e3 --- /dev/null +++ b/apps/claw-memory-service/src/common/enums/context-pack-scope.enum.ts @@ -0,0 +1 @@ +export { ContextPackScope } from '@claw/shared-types'; diff --git a/apps/claw-memory-service/src/common/enums/context-pack-visibility.enum.ts b/apps/claw-memory-service/src/common/enums/context-pack-visibility.enum.ts new file mode 100644 index 00000000..3787475f --- /dev/null +++ b/apps/claw-memory-service/src/common/enums/context-pack-visibility.enum.ts @@ -0,0 +1 @@ +export { ContextPackVisibility } from '@claw/shared-types'; diff --git a/apps/claw-memory-service/src/common/enums/index.ts b/apps/claw-memory-service/src/common/enums/index.ts index 2937023c..7721a287 100644 --- a/apps/claw-memory-service/src/common/enums/index.ts +++ b/apps/claw-memory-service/src/common/enums/index.ts @@ -1,2 +1,12 @@ -export { UserRole } from "./user-role.enum"; -export { HealthCheckStatus, ServiceStatus } from "./health-status.enum"; +export { UserRole } from './user-role.enum'; +export { HealthCheckStatus, ServiceStatus } from './health-status.enum'; +export { MemoryScope } from './memory-scope.enum'; +export { MemorySource } from './memory-source.enum'; +export { MemorySensitivity } from './memory-sensitivity.enum'; +export { MemoryRetention } from './memory-retention.enum'; +export { MemorySuggestionStatus } from './memory-suggestion-status.enum'; +export { MemoryAuditAction } from './memory-audit-action.enum'; +export { ContextPackScope } from './context-pack-scope.enum'; +export { ContextPackItemType } from './context-pack-item-type.enum'; +export { ContextPackVisibility } from './context-pack-visibility.enum'; +export { RetrievalReason } from './retrieval-reason.enum'; diff --git a/apps/claw-memory-service/src/common/enums/memory-audit-action.enum.ts b/apps/claw-memory-service/src/common/enums/memory-audit-action.enum.ts new file mode 100644 index 00000000..4b7075d6 --- /dev/null +++ b/apps/claw-memory-service/src/common/enums/memory-audit-action.enum.ts @@ -0,0 +1 @@ +export { MemoryAuditAction } from '@claw/shared-types'; diff --git a/apps/claw-memory-service/src/common/enums/memory-retention.enum.ts b/apps/claw-memory-service/src/common/enums/memory-retention.enum.ts new file mode 100644 index 00000000..b8eccbe3 --- /dev/null +++ b/apps/claw-memory-service/src/common/enums/memory-retention.enum.ts @@ -0,0 +1 @@ +export { MemoryRetention } from '@claw/shared-types'; diff --git a/apps/claw-memory-service/src/common/enums/memory-scope.enum.ts b/apps/claw-memory-service/src/common/enums/memory-scope.enum.ts new file mode 100644 index 00000000..707b82e3 --- /dev/null +++ b/apps/claw-memory-service/src/common/enums/memory-scope.enum.ts @@ -0,0 +1 @@ +export { MemoryScope } from '@claw/shared-types'; diff --git a/apps/claw-memory-service/src/common/enums/memory-sensitivity.enum.ts b/apps/claw-memory-service/src/common/enums/memory-sensitivity.enum.ts new file mode 100644 index 00000000..5454ad94 --- /dev/null +++ b/apps/claw-memory-service/src/common/enums/memory-sensitivity.enum.ts @@ -0,0 +1 @@ +export { MemorySensitivity } from '@claw/shared-types'; diff --git a/apps/claw-memory-service/src/common/enums/memory-source.enum.ts b/apps/claw-memory-service/src/common/enums/memory-source.enum.ts new file mode 100644 index 00000000..6f5ac6bf --- /dev/null +++ b/apps/claw-memory-service/src/common/enums/memory-source.enum.ts @@ -0,0 +1 @@ +export { MemorySource } from '@claw/shared-types'; diff --git a/apps/claw-memory-service/src/common/enums/memory-suggestion-status.enum.ts b/apps/claw-memory-service/src/common/enums/memory-suggestion-status.enum.ts new file mode 100644 index 00000000..ef9cc8d8 --- /dev/null +++ b/apps/claw-memory-service/src/common/enums/memory-suggestion-status.enum.ts @@ -0,0 +1 @@ +export { MemorySuggestionStatus } from '@claw/shared-types'; diff --git a/apps/claw-memory-service/src/common/enums/retrieval-reason.enum.ts b/apps/claw-memory-service/src/common/enums/retrieval-reason.enum.ts new file mode 100644 index 00000000..9dc59574 --- /dev/null +++ b/apps/claw-memory-service/src/common/enums/retrieval-reason.enum.ts @@ -0,0 +1 @@ +export { RetrievalReason } from '@claw/shared-types'; diff --git a/apps/claw-memory-service/src/common/utilities/date-coerce.utility.ts b/apps/claw-memory-service/src/common/utilities/date-coerce.utility.ts new file mode 100644 index 00000000..bc7bed01 --- /dev/null +++ b/apps/claw-memory-service/src/common/utilities/date-coerce.utility.ts @@ -0,0 +1,9 @@ +export function parsePausedUntil(value: string | null | undefined): Date | null | undefined { + if (value === null) return null; + if (value === undefined) return undefined; + return new Date(value); +} + +export function parseOptionalDate(value: string | null | undefined): Date | null | undefined { + return parsePausedUntil(value); +} diff --git a/apps/claw-memory-service/src/modules/context-packs/__tests__/context-packs.service.spec.ts b/apps/claw-memory-service/src/modules/context-packs/__tests__/context-packs.service.spec.ts index 4600d884..3b58a82c 100644 --- a/apps/claw-memory-service/src/modules/context-packs/__tests__/context-packs.service.spec.ts +++ b/apps/claw-memory-service/src/modules/context-packs/__tests__/context-packs.service.spec.ts @@ -1,272 +1,121 @@ +import { + type ContextPack, + ContextPackItemType, + ContextPackScope, + ContextPackVisibility, +} from '../../../generated/prisma'; import { ContextPacksService } from '../services/context-packs.service'; import { type ContextPacksRepository } from '../repositories/context-packs.repository'; -import { type RabbitMQService } from '@claw/shared-rabbitmq'; -import { BusinessException, EntityNotFoundException } from '../../../common/errors'; -const mockPack = { - id: 'pack-1', - userId: 'user-1', - name: 'My Context Pack', - description: 'A test context pack', - scope: null, - createdAt: new Date(), - updatedAt: new Date(), -}; - -const mockPackWithItems = { - ...mockPack, - items: [ - { - id: 'item-1', - contextPackId: 'pack-1', - type: 'text', - content: 'Some context', - fileId: null, - sortOrder: 0, - createdAt: new Date(), +function makeStub(): T { + const cache: Record = {}; + return new Proxy({} as T, { + get: (_target, prop) => { + if (!cache[prop]) { + cache[prop] = jest.fn(); + } + return cache[prop]; }, - ], -}; - -const mockItem = { - id: 'item-1', - contextPackId: 'pack-1', - type: 'text', - content: 'Some context', - fileId: null, - sortOrder: 0, - createdAt: new Date(), -}; - -const mockContextPacksRepository = (): Record => ({ - create: jest.fn(), - findById: jest.fn(), - findAll: jest.fn(), - update: jest.fn(), - delete: jest.fn(), - countAll: jest.fn(), - addItem: jest.fn(), - removeItem: jest.fn(), - reorderItems: jest.fn(), -}); - -const mockRabbitMQ = (): Partial> => ({ - publish: jest.fn().mockResolvedValue(void 0), -}); - -describe('ContextPacksService', () => { - let service: ContextPacksService; - let packsRepo: ReturnType; - let rabbitMQ: ReturnType; - - beforeEach(() => { - packsRepo = mockContextPacksRepository(); - rabbitMQ = mockRabbitMQ(); - service = new ContextPacksService( - packsRepo as unknown as ContextPacksRepository, - rabbitMQ as unknown as RabbitMQService, - ); - }); - - describe('createContextPack', () => { - it('should create a context pack and publish event', async () => { - packsRepo.create.mockResolvedValue(mockPack); - - const result = await service.createContextPack('user-1', { - name: 'My Context Pack', - description: 'A test context pack', - }); - - expect(result).toEqual(mockPack); - expect(packsRepo.create).toHaveBeenCalledWith({ - userId: 'user-1', - name: 'My Context Pack', - description: 'A test context pack', - scope: undefined, - }); - expect(rabbitMQ.publish).toHaveBeenCalledWith( - 'context_pack.updated', - expect.objectContaining({ - contextPackId: 'pack-1', - action: 'created', - }), - ); - }); }); +} + +function buildPack(overrides: Partial = {}): ContextPack { + return { + id: 'pack-1', + userId: 'user-1', + name: 'Demo pack', + description: null, + scope: ContextPackScope.USER, + scopeRef: null, + legacyScope: null, + tags: [], + visibility: ContextPackVisibility.PRIVATE, + isEnabled: true, + pausedUntil: null, + pinned: false, + color: null, + icon: null, + version: 1, + templateId: null, + ownerUserId: 'user-1', + useCount: 0, + lastUsedAt: null, + qualityScore: 0.5, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +describe('ContextPacksService (V2)', () => { + it('createContextPack passes the new V2 fields through to the repository', async () => { + const repo = makeStub(); + const rabbit = { publish: jest.fn(), subscribe: jest.fn() }; + const created = buildPack({ name: 'Engineering' }); + (repo.create as unknown as jest.Mock).mockResolvedValue(created); + + const service = new ContextPacksService( + repo, + rabbit as unknown as ConstructorParameters[1], + ); - describe('getContextPacks', () => { - it('should return paginated context packs', async () => { - packsRepo.findAll.mockResolvedValue([mockPack]); - packsRepo.countAll.mockResolvedValue(1); - - const result = await service.getContextPacks('user-1', 1, 20); - - expect(result.data).toHaveLength(1); - expect(result.meta.total).toBe(1); - expect(result.meta.page).toBe(1); - expect(result.meta.totalPages).toBe(1); - }); - - it('should pass search filter to repository', async () => { - packsRepo.findAll.mockResolvedValue([]); - packsRepo.countAll.mockResolvedValue(0); - - await service.getContextPacks('user-1', 1, 20, 'test'); - - expect(packsRepo.findAll).toHaveBeenCalledWith({ userId: 'user-1', search: 'test' }, 1, 20); - }); - }); - - describe('getContextPack', () => { - it('should return context pack with items when found', async () => { - packsRepo.findById.mockResolvedValue(mockPackWithItems); - - const result = await service.getContextPack('pack-1', 'user-1'); - - expect(result).toEqual(mockPackWithItems); - expect(result.items).toHaveLength(1); - }); - - it('should throw EntityNotFoundException when not found', async () => { - packsRepo.findById.mockResolvedValue(null); - - await expect(service.getContextPack('nonexistent', 'user-1')).rejects.toThrow( - EntityNotFoundException, - ); - }); - - it('should throw BusinessException when user does not own pack', async () => { - packsRepo.findById.mockResolvedValue(mockPackWithItems); - - await expect(service.getContextPack('pack-1', 'other-user')).rejects.toThrow( - BusinessException, - ); - }); - }); - - describe('updateContextPack', () => { - it('should update context pack and publish event', async () => { - const updated = { ...mockPack, name: 'Updated Pack' }; - packsRepo.findById.mockResolvedValue(mockPackWithItems); - packsRepo.update.mockResolvedValue(updated); - - const result = await service.updateContextPack('pack-1', 'user-1', { - name: 'Updated Pack', - }); - - expect(result.name).toBe('Updated Pack'); - expect(rabbitMQ.publish).toHaveBeenCalledWith( - 'context_pack.updated', - expect.objectContaining({ - contextPackId: 'pack-1', - action: 'updated', - }), - ); - }); - - it('should throw EntityNotFoundException when not found', async () => { - packsRepo.findById.mockResolvedValue(null); - - await expect( - service.updateContextPack('nonexistent', 'user-1', { name: 'New' }), - ).rejects.toThrow(EntityNotFoundException); - }); - }); - - describe('deleteContextPack', () => { - it('should delete context pack and publish event', async () => { - packsRepo.findById.mockResolvedValue(mockPackWithItems); - packsRepo.delete.mockResolvedValue(mockPack); - - const result = await service.deleteContextPack('pack-1', 'user-1'); - - expect(result).toEqual(mockPack); - expect(rabbitMQ.publish).toHaveBeenCalledWith( - 'context_pack.updated', - expect.objectContaining({ - contextPackId: 'pack-1', - action: 'deleted', - }), - ); + const pack = await service.createContextPack('user-1', { + name: 'Engineering', + description: 'Style guide', + scope: ContextPackScope.WORKSPACE, + scopeRef: 'workspace-9', + tags: ['eng'], + visibility: ContextPackVisibility.WORKSPACE, }); - it('should throw EntityNotFoundException when not found', async () => { - packsRepo.findById.mockResolvedValue(null); - - await expect(service.deleteContextPack('nonexistent', 'user-1')).rejects.toThrow( - EntityNotFoundException, - ); - }); + expect(pack).toEqual(created); + expect(repo.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + ownerUserId: 'user-1', + scope: ContextPackScope.WORKSPACE, + scopeRef: 'workspace-9', + visibility: ContextPackVisibility.WORKSPACE, + }), + ); }); - describe('addItem', () => { - it('should add item to context pack and publish event', async () => { - packsRepo.findById.mockResolvedValue(mockPackWithItems); - packsRepo.addItem.mockResolvedValue(mockItem); - - const result = await service.addItem('pack-1', 'user-1', { - type: 'text', - content: 'Some context', - }); - - expect(result).toEqual(mockItem); - expect(packsRepo.addItem).toHaveBeenCalledWith({ - contextPackId: 'pack-1', - type: 'text', - content: 'Some context', - fileId: undefined, - sortOrder: undefined, - }); - expect(rabbitMQ.publish).toHaveBeenCalledWith( - 'context_pack.updated', - expect.objectContaining({ - contextPackId: 'pack-1', - action: 'item_added', - }), - ); - }); - - it('should throw EntityNotFoundException when pack not found', async () => { - packsRepo.findById.mockResolvedValue(null); - - await expect( - service.addItem('nonexistent', 'user-1', { type: 'text', content: 'test' }), - ).rejects.toThrow(EntityNotFoundException); - }); - - it('should throw BusinessException when user does not own pack', async () => { - packsRepo.findById.mockResolvedValue(mockPackWithItems); - - await expect( - service.addItem('pack-1', 'other-user', { type: 'text', content: 'test' }), - ).rejects.toThrow(BusinessException); + it('resolves a legacy free-text item.type to a V2 enum', async () => { + const repo = makeStub(); + const rabbit = { publish: jest.fn(), subscribe: jest.fn() }; + (repo.findById as unknown as jest.Mock).mockResolvedValue({ + ...buildPack(), + items: [], }); - }); - - describe('removeItem', () => { - it('should remove item from context pack and publish event', async () => { - packsRepo.findById.mockResolvedValue(mockPackWithItems); - packsRepo.removeItem.mockResolvedValue(mockItem); + (repo.addItem as unknown as jest.Mock).mockImplementation(async (input) => ({ + id: 'item-1', + contextPackId: input.contextPackId, + itemType: input.itemType, + legacyType: input.legacyType ?? null, + content: input.content ?? null, + fileId: input.fileId ?? null, + url: input.url ?? null, + memoryRefId: input.memoryRefId ?? null, + sortOrder: input.sortOrder ?? 0, + isEnabled: true, + pinned: false, + tokenCountEstimate: input.tokenCountEstimate ?? 0, + compressedSummary: null, + createdAt: new Date(), + updatedAt: new Date(), + })); - const result = await service.removeItem('pack-1', 'item-1', 'user-1'); + const service = new ContextPacksService( + repo, + rabbit as unknown as ConstructorParameters[1], + ); - expect(result).toEqual(mockItem); - expect(packsRepo.removeItem).toHaveBeenCalledWith('item-1'); - expect(rabbitMQ.publish).toHaveBeenCalledWith( - 'context_pack.updated', - expect.objectContaining({ - contextPackId: 'pack-1', - action: 'item_removed', - }), - ); + const item = await service.addItem('pack-1', 'user-1', { + type: 'snippet', + content: 'console.log("x")', }); - it('should throw EntityNotFoundException when pack not found', async () => { - packsRepo.findById.mockResolvedValue(null); - - await expect(service.removeItem('nonexistent', 'item-1', 'user-1')).rejects.toThrow( - EntityNotFoundException, - ); - }); + expect(item.itemType).toBe(ContextPackItemType.SNIPPET); + expect(item.legacyType).toBe('snippet'); }); }); diff --git a/apps/claw-memory-service/src/modules/context-packs/dto/add-context-pack-item.dto.ts b/apps/claw-memory-service/src/modules/context-packs/dto/add-context-pack-item.dto.ts index 6c710045..b777c89a 100644 --- a/apps/claw-memory-service/src/modules/context-packs/dto/add-context-pack-item.dto.ts +++ b/apps/claw-memory-service/src/modules/context-packs/dto/add-context-pack-item.dto.ts @@ -1,10 +1,17 @@ -import { z } from "zod"; +import { z } from 'zod'; +import { ContextPackItemType } from '../../../generated/prisma'; export const addContextPackItemSchema = z.object({ - type: z.string().min(1, "Type is required").max(50, "Type must be at most 50 characters"), - content: z.string().max(50000, "Content must be at most 50000 characters").optional(), - fileId: z.string().max(255, "File ID must be at most 255 characters").optional(), + itemType: z.nativeEnum(ContextPackItemType).optional(), + // Back-compat for old callers that sent free-text type + type: z.string().max(50).optional(), + content: z.string().max(50000).optional(), + fileId: z.string().max(255).optional(), + url: z.string().url().max(2048).optional(), + memoryRefId: z.string().max(64).optional(), sortOrder: z.number().int().min(0).max(10000).optional(), + isEnabled: z.boolean().optional(), + pinned: z.boolean().optional(), }); export type AddContextPackItemDto = z.infer; diff --git a/apps/claw-memory-service/src/modules/context-packs/dto/create-context-pack.dto.ts b/apps/claw-memory-service/src/modules/context-packs/dto/create-context-pack.dto.ts index b3951f72..f133f1b7 100644 --- a/apps/claw-memory-service/src/modules/context-packs/dto/create-context-pack.dto.ts +++ b/apps/claw-memory-service/src/modules/context-packs/dto/create-context-pack.dto.ts @@ -1,9 +1,19 @@ -import { z } from "zod"; +import { z } from 'zod'; +import { ContextPackScope, ContextPackVisibility } from '../../../generated/prisma'; export const createContextPackSchema = z.object({ - name: z.string().min(1, "Name is required").max(255, "Name must be at most 255 characters"), - description: z.string().max(1000, "Description must be at most 1000 characters").optional(), - scope: z.string().max(255, "Scope must be at most 255 characters").optional(), + name: z.string().min(1).max(255), + description: z.string().max(1000).optional(), + scope: z.nativeEnum(ContextPackScope).optional(), + scopeRef: z.string().max(255).optional(), + // Back-compat: callers using the v1 free-text "scope" land here. + legacyScope: z.string().max(255).optional(), + tags: z.array(z.string().min(1).max(64)).max(20).optional(), + visibility: z.nativeEnum(ContextPackVisibility).optional(), + color: z.string().max(32).optional(), + icon: z.string().max(64).optional(), + templateId: z.string().max(64).optional(), + pinned: z.boolean().optional(), }); export type CreateContextPackDto = z.infer; diff --git a/apps/claw-memory-service/src/modules/context-packs/dto/update-context-pack.dto.ts b/apps/claw-memory-service/src/modules/context-packs/dto/update-context-pack.dto.ts index 769f0d2e..7d239bbc 100644 --- a/apps/claw-memory-service/src/modules/context-packs/dto/update-context-pack.dto.ts +++ b/apps/claw-memory-service/src/modules/context-packs/dto/update-context-pack.dto.ts @@ -1,9 +1,18 @@ -import { z } from "zod"; +import { z } from 'zod'; +import { ContextPackScope, ContextPackVisibility } from '../../../generated/prisma'; export const updateContextPackSchema = z.object({ - name: z.string().min(1, "Name is required").max(255, "Name must be at most 255 characters").optional(), - description: z.string().max(1000, "Description must be at most 1000 characters").optional(), - scope: z.string().max(255, "Scope must be at most 255 characters").optional(), + name: z.string().min(1).max(255).optional(), + description: z.string().max(1000).optional(), + scope: z.nativeEnum(ContextPackScope).optional(), + scopeRef: z.string().max(255).nullable().optional(), + tags: z.array(z.string().min(1).max(64)).max(20).optional(), + visibility: z.nativeEnum(ContextPackVisibility).optional(), + isEnabled: z.boolean().optional(), + pausedUntil: z.string().datetime().nullable().optional(), + pinned: z.boolean().optional(), + color: z.string().max(32).nullable().optional(), + icon: z.string().max(64).nullable().optional(), }); export type UpdateContextPackDto = z.infer; diff --git a/apps/claw-memory-service/src/modules/context-packs/repositories/__tests__/context-packs.repository.spec.ts b/apps/claw-memory-service/src/modules/context-packs/repositories/__tests__/context-packs.repository.spec.ts index 44bd755b..38c28341 100644 --- a/apps/claw-memory-service/src/modules/context-packs/repositories/__tests__/context-packs.repository.spec.ts +++ b/apps/claw-memory-service/src/modules/context-packs/repositories/__tests__/context-packs.repository.spec.ts @@ -71,11 +71,11 @@ describe('ContextPacksRepository', () => { expect(args.where.name).toEqual({ contains: 'pack', mode: 'insensitive' }); }); - it('update delegates to prisma', async () => { + it('update delegates to prisma and auto-increments version (V2)', async () => { await repository.update('cp1', { name: 'renamed' }); expect(prismaMock.contextPack.update).toHaveBeenCalledWith({ where: { id: 'cp1' }, - data: { name: 'renamed' }, + data: { name: 'renamed', version: { increment: 1 } }, }); }); diff --git a/apps/claw-memory-service/src/modules/context-packs/repositories/context-packs.repository.ts b/apps/claw-memory-service/src/modules/context-packs/repositories/context-packs.repository.ts index 2a4f60e4..89f51359 100644 --- a/apps/claw-memory-service/src/modules/context-packs/repositories/context-packs.repository.ts +++ b/apps/claw-memory-service/src/modules/context-packs/repositories/context-packs.repository.ts @@ -1,49 +1,74 @@ -import { Injectable } from "@nestjs/common"; -import { type ContextPack, type ContextPackItem, Prisma } from "../../../generated/prisma"; -import { PrismaService } from "../../../infrastructure/database/prisma/prisma.service"; +import { Injectable } from '@nestjs/common'; +import { type ContextPack, type ContextPackItem, Prisma } from '../../../generated/prisma'; +import { PrismaService } from '../../../infrastructure/database/prisma/prisma.service'; import { type AddContextPackItemData, type ContextPackFilters, type ContextPackWithItems, type CreateContextPackData, type UpdateContextPackData, -} from "../types/context-packs.types"; + type UpdateContextPackItemData, +} from '../types/context-packs.types'; @Injectable() export class ContextPacksRepository { constructor(private readonly prisma: PrismaService) {} async create(data: CreateContextPackData): Promise { - return this.prisma.contextPack.create({ data }); + return this.prisma.contextPack.create({ + data: { + userId: data.userId, + ownerUserId: data.ownerUserId ?? data.userId, + name: data.name, + description: data.description, + scope: data.scope, + scopeRef: data.scopeRef, + legacyScope: data.legacyScope, + tags: data.tags ?? undefined, + visibility: data.visibility, + color: data.color, + icon: data.icon, + templateId: data.templateId, + pinned: data.pinned, + }, + }); } async findById(id: string): Promise { return this.prisma.contextPack.findUnique({ where: { id }, - include: { items: { orderBy: { sortOrder: "asc" } } }, + include: { items: { orderBy: { sortOrder: 'asc' } } }, }); } - async findAll( - filters: ContextPackFilters, - page: number, - limit: number, - ): Promise { + async findAll(filters: ContextPackFilters, page: number, limit: number): Promise { const where = this.buildWhereClause(filters); const skip = (page - 1) * limit; - return this.prisma.contextPack.findMany({ where, skip, take: limit, - orderBy: { createdAt: "desc" }, + orderBy: { createdAt: 'desc' }, }); } async update(id: string, data: UpdateContextPackData): Promise { return this.prisma.contextPack.update({ where: { id }, - data, + data: { + ...(data.name !== undefined ? { name: data.name } : {}), + ...(data.description !== undefined ? { description: data.description } : {}), + ...(data.scope !== undefined ? { scope: data.scope } : {}), + ...(data.scopeRef !== undefined ? { scopeRef: data.scopeRef } : {}), + ...(data.tags !== undefined ? { tags: data.tags } : {}), + ...(data.visibility !== undefined ? { visibility: data.visibility } : {}), + ...(data.isEnabled !== undefined ? { isEnabled: data.isEnabled } : {}), + ...(data.pausedUntil !== undefined ? { pausedUntil: data.pausedUntil } : {}), + ...(data.pinned !== undefined ? { pinned: data.pinned } : {}), + ...(data.color !== undefined ? { color: data.color } : {}), + ...(data.icon !== undefined ? { icon: data.icon } : {}), + version: { increment: 1 }, + }, }); } @@ -56,8 +81,55 @@ export class ContextPacksRepository { return this.prisma.contextPack.count({ where }); } + async findAttachmentsForScope( + scope: ContextPackFilters['scope'], + scopeRef: string, + limit = 50, + ): Promise { + if (scope === undefined) return []; + return this.prisma.contextPack.findMany({ + where: { + attachments: { some: { scope, scopeRef, isActive: true } }, + }, + take: limit, + }); + } + async addItem(data: AddContextPackItemData): Promise { - return this.prisma.contextPackItem.create({ data }); + return this.prisma.contextPackItem.create({ + data: { + contextPackId: data.contextPackId, + itemType: data.itemType, + legacyType: data.legacyType, + content: data.content, + fileId: data.fileId, + url: data.url, + memoryRefId: data.memoryRefId, + sortOrder: data.sortOrder ?? 0, + isEnabled: data.isEnabled, + pinned: data.pinned, + tokenCountEstimate: data.tokenCountEstimate, + }, + }); + } + + async updateItem(id: string, data: UpdateContextPackItemData): Promise { + return this.prisma.contextPackItem.update({ + where: { id }, + data: { + ...(data.itemType !== undefined ? { itemType: data.itemType } : {}), + ...(data.content !== undefined ? { content: data.content } : {}), + ...(data.fileId !== undefined ? { fileId: data.fileId } : {}), + ...(data.url !== undefined ? { url: data.url } : {}), + ...(data.memoryRefId !== undefined ? { memoryRefId: data.memoryRefId } : {}), + ...(data.sortOrder !== undefined ? { sortOrder: data.sortOrder } : {}), + ...(data.isEnabled !== undefined ? { isEnabled: data.isEnabled } : {}), + ...(data.pinned !== undefined ? { pinned: data.pinned } : {}), + ...(data.tokenCountEstimate !== undefined + ? { tokenCountEstimate: data.tokenCountEstimate } + : {}), + }, + }); } async removeItem(id: string): Promise { @@ -75,14 +147,14 @@ export class ContextPacksRepository { } private buildWhereClause(filters: ContextPackFilters): Prisma.ContextPackWhereInput { - const where: Prisma.ContextPackWhereInput = { - userId: filters.userId, - }; - + const where: Prisma.ContextPackWhereInput = { userId: filters.userId }; if (filters.search) { - where.name = { contains: filters.search, mode: "insensitive" }; + where.name = { contains: filters.search, mode: 'insensitive' }; } - + if (filters.scope !== undefined) where.scope = filters.scope; + if (filters.scopeRef !== undefined) where.scopeRef = filters.scopeRef; + if (filters.visibility !== undefined) where.visibility = filters.visibility; + if (filters.enabledOnly) where.isEnabled = true; return where; } } diff --git a/apps/claw-memory-service/src/modules/context-packs/services/context-packs.service.ts b/apps/claw-memory-service/src/modules/context-packs/services/context-packs.service.ts index 42979c84..7c828a38 100644 --- a/apps/claw-memory-service/src/modules/context-packs/services/context-packs.service.ts +++ b/apps/claw-memory-service/src/modules/context-packs/services/context-packs.service.ts @@ -1,6 +1,10 @@ import { HttpStatus, Injectable, Logger } from '@nestjs/common'; import { RabbitMQService } from '@claw/shared-rabbitmq'; -import { type ContextPack, type ContextPackItem } from '../../../generated/prisma'; +import { + type ContextPack, + type ContextPackItem, + ContextPackItemType, +} from '../../../generated/prisma'; import { BusinessException, EntityNotFoundException } from '../../../common/errors'; import { type PaginatedResult } from '../../../common/types'; import { ContextPacksRepository } from '../repositories/context-packs.repository'; @@ -9,6 +13,7 @@ import { type UpdateContextPackDto } from '../dto/update-context-pack.dto'; import { type AddContextPackItemDto } from '../dto/add-context-pack-item.dto'; import { type ContextPackWithItems } from '../types/context-packs.types'; import { CONTEXT_PACK_UPDATED_EVENT } from '../constants/context-packs.constants'; +import { parsePausedUntil } from '../../../common/utilities/date-coerce.utility'; @Injectable() export class ContextPacksService { @@ -20,21 +25,28 @@ export class ContextPacksService { ) {} async createContextPack(userId: string, dto: CreateContextPackDto): Promise { - this.logger.log(`createContextPack: creating pack "${dto.name}" for user ${userId}`); + this.logger.log(`createContextPack: pack="${dto.name}" userId=${userId}`); const pack = await this.contextPacksRepository.create({ userId, + ownerUserId: userId, name: dto.name, description: dto.description, scope: dto.scope, + scopeRef: dto.scopeRef, + legacyScope: dto.legacyScope, + tags: dto.tags, + visibility: dto.visibility, + color: dto.color, + icon: dto.icon, + templateId: dto.templateId, + pinned: dto.pinned, }); - void this.rabbitMQService.publish(CONTEXT_PACK_UPDATED_EVENT, { contextPackId: pack.id, userId, action: 'created', timestamp: new Date().toISOString(), }); - return pack; } @@ -45,12 +57,10 @@ export class ContextPacksService { search?: string, ): Promise> { const filters = { userId, search }; - const [packs, total] = await Promise.all([ this.contextPacksRepository.findAll(filters, page, limit), this.contextPacksRepository.countAll(filters), ]); - return { data: packs, meta: { @@ -81,40 +91,42 @@ export class ContextPacksService { throw new EntityNotFoundException('ContextPack', id); } this.validateOwnership(pack, userId); - const updated = await this.contextPacksRepository.update(id, { name: dto.name, description: dto.description, scope: dto.scope, + scopeRef: dto.scopeRef === undefined ? undefined : dto.scopeRef, + tags: dto.tags, + visibility: dto.visibility, + isEnabled: dto.isEnabled, + pausedUntil: parsePausedUntil(dto.pausedUntil), + pinned: dto.pinned, + color: dto.color, + icon: dto.icon, }); - void this.rabbitMQService.publish(CONTEXT_PACK_UPDATED_EVENT, { contextPackId: id, userId, action: 'updated', timestamp: new Date().toISOString(), }); - return updated; } async deleteContextPack(id: string, userId: string): Promise { - this.logger.log(`deleteContextPack: deleting pack ${id}`); + this.logger.log(`deleteContextPack: id=${id}`); const pack = await this.contextPacksRepository.findById(id); if (!pack) { throw new EntityNotFoundException('ContextPack', id); } this.validateOwnership(pack, userId); - const deleted = await this.contextPacksRepository.delete(id); - void this.rabbitMQService.publish(CONTEXT_PACK_UPDATED_EVENT, { contextPackId: id, userId, action: 'deleted', timestamp: new Date().toISOString(), }); - return deleted; } @@ -123,28 +135,31 @@ export class ContextPacksService { userId: string, dto: AddContextPackItemDto, ): Promise { - this.logger.log(`addItem: adding item type=${dto.type} to pack ${contextPackId}`); const pack = await this.contextPacksRepository.findById(contextPackId); if (!pack) { throw new EntityNotFoundException('ContextPack', contextPackId); } this.validateOwnership(pack, userId); - + const itemType = this.resolveItemType(dto.itemType, dto.type); const item = await this.contextPacksRepository.addItem({ contextPackId, - type: dto.type, + itemType, + legacyType: dto.type, content: dto.content, fileId: dto.fileId, + url: dto.url, + memoryRefId: dto.memoryRefId, sortOrder: dto.sortOrder, + isEnabled: dto.isEnabled, + pinned: dto.pinned, + tokenCountEstimate: dto.content ? Math.ceil(dto.content.length / 4) : 0, }); - void this.rabbitMQService.publish(CONTEXT_PACK_UPDATED_EVENT, { contextPackId, userId, action: 'item_added', timestamp: new Date().toISOString(), }); - return item; } @@ -153,22 +168,18 @@ export class ContextPacksService { itemId: string, userId: string, ): Promise { - this.logger.log(`removeItem: removing item ${itemId} from pack ${contextPackId}`); const pack = await this.contextPacksRepository.findById(contextPackId); if (!pack) { throw new EntityNotFoundException('ContextPack', contextPackId); } this.validateOwnership(pack, userId); - const removed = await this.contextPacksRepository.removeItem(itemId); - void this.rabbitMQService.publish(CONTEXT_PACK_UPDATED_EVENT, { contextPackId, userId, action: 'item_removed', timestamp: new Date().toISOString(), }); - return removed; } @@ -185,4 +196,19 @@ export class ContextPacksService { ); } } + + private resolveItemType( + explicit: ContextPackItemType | undefined, + legacy: string | undefined, + ): ContextPackItemType { + if (explicit !== undefined) return explicit; + if (legacy === undefined) return ContextPackItemType.TEXT; + const lower = legacy.toLowerCase(); + if (lower.startsWith('file')) return ContextPackItemType.FILE; + if (lower.startsWith('url')) return ContextPackItemType.URL; + if (lower.startsWith('markdown')) return ContextPackItemType.MARKDOWN; + if (lower.startsWith('snippet') || lower.startsWith('code')) return ContextPackItemType.SNIPPET; + if (lower.startsWith('memory')) return ContextPackItemType.MEMORY_REF; + return ContextPackItemType.TEXT; + } } diff --git a/apps/claw-memory-service/src/modules/context-packs/types/context-packs.types.ts b/apps/claw-memory-service/src/modules/context-packs/types/context-packs.types.ts index 88ab2c96..bf323cd5 100644 --- a/apps/claw-memory-service/src/modules/context-packs/types/context-packs.types.ts +++ b/apps/claw-memory-service/src/modules/context-packs/types/context-packs.types.ts @@ -1,29 +1,75 @@ -import { type ContextPack, type ContextPackItem } from "../../../generated/prisma"; +import type { + ContextPack, + ContextPackItem, + ContextPackItemType, + ContextPackScope, + ContextPackVisibility, +} from '../../../generated/prisma'; export interface CreateContextPackData { userId: string; + ownerUserId?: string; name: string; description?: string; - scope?: string; + // V2 additions + scope?: ContextPackScope; + scopeRef?: string; + legacyScope?: string; + tags?: string[]; + visibility?: ContextPackVisibility; + color?: string; + icon?: string; + templateId?: string; + pinned?: boolean; } export interface UpdateContextPackData { name?: string; description?: string; - scope?: string; + scope?: ContextPackScope; + scopeRef?: string | null; + tags?: string[]; + visibility?: ContextPackVisibility; + isEnabled?: boolean; + pausedUntil?: Date | null; + pinned?: boolean; + color?: string | null; + icon?: string | null; } export interface AddContextPackItemData { contextPackId: string; - type: string; + itemType?: ContextPackItemType; + legacyType?: string; content?: string; fileId?: string; + url?: string; + memoryRefId?: string; sortOrder?: number; + isEnabled?: boolean; + pinned?: boolean; + tokenCountEstimate?: number; +} + +export interface UpdateContextPackItemData { + itemType?: ContextPackItemType; + content?: string | null; + fileId?: string | null; + url?: string | null; + memoryRefId?: string | null; + sortOrder?: number; + isEnabled?: boolean; + pinned?: boolean; + tokenCountEstimate?: number; } export interface ContextPackFilters { userId: string; search?: string; + scope?: ContextPackScope; + scopeRef?: string; + visibility?: ContextPackVisibility; + enabledOnly?: boolean; } export type ContextPackWithItems = ContextPack & { diff --git a/apps/claw-memory-service/src/modules/memory-audit/controllers/memory-audit.controller.ts b/apps/claw-memory-service/src/modules/memory-audit/controllers/memory-audit.controller.ts new file mode 100644 index 00000000..75d2639a --- /dev/null +++ b/apps/claw-memory-service/src/modules/memory-audit/controllers/memory-audit.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, Param, Query } from '@nestjs/common'; +import type { MemoryAuditLog } from '../../../generated/prisma'; +import { CurrentUser } from '../../../app/decorators/current-user.decorator'; +import type { AuthenticatedUser } from '../../../common/types'; +import { MemoryAuditService } from '../services/memory-audit.service'; + +@Controller('memory-audit') +export class MemoryAuditController { + constructor(private readonly service: MemoryAuditService) {} + + @Get() + async listMine( + @CurrentUser() user: AuthenticatedUser, + @Query('limit') limit: string | undefined, + ): Promise { + const parsed = Math.min(Math.max(Number(limit) || 100, 1), 500); + return this.service.getUserHistory(user.id, parsed); + } + + @Get(':memoryId') + async listForMemory( + @Param('memoryId') memoryId: string, + @CurrentUser() user: AuthenticatedUser, + ): Promise { + return this.service.getMemoryHistory(memoryId, user.id); + } +} diff --git a/apps/claw-memory-service/src/modules/memory-audit/repositories/memory-audit-log.repository.ts b/apps/claw-memory-service/src/modules/memory-audit/repositories/memory-audit-log.repository.ts new file mode 100644 index 00000000..88172f86 --- /dev/null +++ b/apps/claw-memory-service/src/modules/memory-audit/repositories/memory-audit-log.repository.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; +import { type MemoryAuditLog, Prisma } from '../../../generated/prisma'; +import { PrismaService } from '../../../infrastructure/database/prisma/prisma.service'; +import type { WriteAuditLogData } from '../types/memory-audit.types'; + +@Injectable() +export class MemoryAuditLogRepository { + constructor(private readonly prisma: PrismaService) {} + + async write(data: WriteAuditLogData): Promise { + return this.prisma.memoryAuditLog.create({ + data: { + userId: data.userId, + memoryId: data.memoryId ?? null, + action: data.action, + actor: data.actor, + details: + data.details === undefined || data.details === null + ? Prisma.JsonNull + : (data.details as Prisma.InputJsonValue), + }, + }); + } + + async findByMemoryId(memoryId: string, limit = 100): Promise { + return this.prisma.memoryAuditLog.findMany({ + where: { memoryId }, + orderBy: { createdAt: 'desc' }, + take: limit, + }); + } + + async findByUserId(userId: string, limit = 100): Promise { + return this.prisma.memoryAuditLog.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + take: limit, + }); + } +} diff --git a/apps/claw-memory-service/src/modules/memory-audit/services/memory-audit.service.ts b/apps/claw-memory-service/src/modules/memory-audit/services/memory-audit.service.ts new file mode 100644 index 00000000..726d888e --- /dev/null +++ b/apps/claw-memory-service/src/modules/memory-audit/services/memory-audit.service.ts @@ -0,0 +1,38 @@ +import { Injectable, Logger } from '@nestjs/common'; +import type { MemoryAuditLog } from '../../../generated/prisma'; +import { MemoryAuditLogRepository } from '../repositories/memory-audit-log.repository'; +import type { WriteAuditLogData } from '../types/memory-audit.types'; + +@Injectable() +export class MemoryAuditService { + private readonly logger = new Logger(MemoryAuditService.name); + + constructor(private readonly repo: MemoryAuditLogRepository) {} + + async record(data: WriteAuditLogData): Promise { + this.logger.debug( + `record: action=${data.action} memoryId=${data.memoryId ?? '(none)'} actor=${data.actor}`, + ); + try { + const row = await this.repo.write(data); + this.logger.log( + `record: persisted audit row id=${row.id} action=${data.action} userId=${data.userId}`, + ); + return row; + } catch (error) { + const msg = error instanceof Error ? error.message : 'unknown'; + this.logger.error(`record: failed to persist audit row — ${msg}`); + throw error; + } + } + + async getMemoryHistory(memoryId: string, userId: string): Promise { + this.logger.debug(`getMemoryHistory: memoryId=${memoryId} userId=${userId}`); + const rows = await this.repo.findByMemoryId(memoryId); + return rows.filter((row) => row.userId === userId); + } + + async getUserHistory(userId: string, limit?: number): Promise { + return this.repo.findByUserId(userId, limit); + } +} diff --git a/apps/claw-memory-service/src/modules/memory-audit/types/memory-audit.types.ts b/apps/claw-memory-service/src/modules/memory-audit/types/memory-audit.types.ts new file mode 100644 index 00000000..c2e9c76a --- /dev/null +++ b/apps/claw-memory-service/src/modules/memory-audit/types/memory-audit.types.ts @@ -0,0 +1,9 @@ +import type { MemoryAuditAction } from '../../../generated/prisma'; + +export type WriteAuditLogData = { + userId: string; + memoryId?: string | null; + action: MemoryAuditAction; + actor: string; + details?: Record | null; +}; diff --git a/apps/claw-memory-service/src/modules/memory-preferences/constants/memory-preference.constants.ts b/apps/claw-memory-service/src/modules/memory-preferences/constants/memory-preference.constants.ts new file mode 100644 index 00000000..3840eda4 --- /dev/null +++ b/apps/claw-memory-service/src/modules/memory-preferences/constants/memory-preference.constants.ts @@ -0,0 +1,10 @@ +import { MemoryRetention } from '../../../generated/prisma'; +import type { MemoryPreferenceDefaults } from '../types/memory-preference.types'; + +export const DEFAULT_MEMORY_PREFERENCE: MemoryPreferenceDefaults = { + pausedAll: false, + autoApproveThreshold: 0.85, + defaultRetention: MemoryRetention.PERMANENT, + defaultExpiresInDays: null, + redactByDefault: true, +}; diff --git a/apps/claw-memory-service/src/modules/memory-preferences/controllers/memory-preferences.controller.ts b/apps/claw-memory-service/src/modules/memory-preferences/controllers/memory-preferences.controller.ts new file mode 100644 index 00000000..3b205249 --- /dev/null +++ b/apps/claw-memory-service/src/modules/memory-preferences/controllers/memory-preferences.controller.ts @@ -0,0 +1,29 @@ +import { Body, Controller, Get, Put } from '@nestjs/common'; +import type { MemoryPreference } from '../../../generated/prisma'; +import { ZodValidationPipe } from '../../../app/pipes/zod-validation.pipe'; +import { CurrentUser } from '../../../app/decorators/current-user.decorator'; +import type { AuthenticatedUser } from '../../../common/types'; +import { MemoryPreferenceService } from '../services/memory-preference.service'; +import { + type UpsertMemoryPreferenceDto, + upsertMemoryPreferenceSchema, +} from '../dto/upsert-memory-preference.dto'; + +@Controller('memory-preferences') +export class MemoryPreferencesController { + constructor(private readonly service: MemoryPreferenceService) {} + + @Get() + async getMine(@CurrentUser() user: AuthenticatedUser): Promise { + return this.service.get(user.id); + } + + @Put() + async upsertMine( + @CurrentUser() user: AuthenticatedUser, + @Body(new ZodValidationPipe(upsertMemoryPreferenceSchema)) + dto: UpsertMemoryPreferenceDto, + ): Promise { + return this.service.upsert(user.id, dto); + } +} diff --git a/apps/claw-memory-service/src/modules/memory-preferences/dto/upsert-memory-preference.dto.ts b/apps/claw-memory-service/src/modules/memory-preferences/dto/upsert-memory-preference.dto.ts new file mode 100644 index 00000000..cd525578 --- /dev/null +++ b/apps/claw-memory-service/src/modules/memory-preferences/dto/upsert-memory-preference.dto.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; +import { MemoryRetention } from '../../../generated/prisma'; + +export const upsertMemoryPreferenceSchema = z.object({ + pausedAll: z.boolean().optional(), + autoApproveThreshold: z.number().min(0).max(1).optional(), + defaultRetention: z.nativeEnum(MemoryRetention).optional(), + defaultExpiresInDays: z.number().int().min(1).max(3650).nullable().optional(), + redactByDefault: z.boolean().optional(), +}); + +export type UpsertMemoryPreferenceDto = z.infer; diff --git a/apps/claw-memory-service/src/modules/memory-preferences/repositories/memory-preference.repository.ts b/apps/claw-memory-service/src/modules/memory-preferences/repositories/memory-preference.repository.ts new file mode 100644 index 00000000..213633b7 --- /dev/null +++ b/apps/claw-memory-service/src/modules/memory-preferences/repositories/memory-preference.repository.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common'; +import { type MemoryPreference, MemoryRetention } from '../../../generated/prisma'; +import { PrismaService } from '../../../infrastructure/database/prisma/prisma.service'; +import type { MemoryPreferencePatch } from '../types/memory-preference.types'; + +export type { MemoryPreferencePatch }; + +@Injectable() +export class MemoryPreferenceRepository { + constructor(private readonly prisma: PrismaService) {} + + async findByUserId(userId: string): Promise { + return this.prisma.memoryPreference.findUnique({ where: { userId } }); + } + + async upsert(userId: string, patch: MemoryPreferencePatch): Promise { + return this.prisma.memoryPreference.upsert({ + where: { userId }, + create: { + userId, + pausedAll: patch.pausedAll ?? false, + autoApproveThreshold: patch.autoApproveThreshold ?? 0.85, + defaultRetention: patch.defaultRetention ?? MemoryRetention.PERMANENT, + defaultExpiresInDays: patch.defaultExpiresInDays ?? null, + redactByDefault: patch.redactByDefault ?? true, + }, + update: { + ...(patch.pausedAll !== undefined ? { pausedAll: patch.pausedAll } : {}), + ...(patch.autoApproveThreshold !== undefined + ? { autoApproveThreshold: patch.autoApproveThreshold } + : {}), + ...(patch.defaultRetention !== undefined + ? { defaultRetention: patch.defaultRetention } + : {}), + ...(patch.defaultExpiresInDays !== undefined + ? { defaultExpiresInDays: patch.defaultExpiresInDays } + : {}), + ...(patch.redactByDefault !== undefined ? { redactByDefault: patch.redactByDefault } : {}), + }, + }); + } +} diff --git a/apps/claw-memory-service/src/modules/memory-preferences/services/memory-preference.service.ts b/apps/claw-memory-service/src/modules/memory-preferences/services/memory-preference.service.ts new file mode 100644 index 00000000..ffcabcb6 --- /dev/null +++ b/apps/claw-memory-service/src/modules/memory-preferences/services/memory-preference.service.ts @@ -0,0 +1,30 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { type MemoryPreference } from '../../../generated/prisma'; +import { DEFAULT_MEMORY_PREFERENCE } from '../constants/memory-preference.constants'; +import { MemoryPreferenceRepository } from '../repositories/memory-preference.repository'; +import type { MemoryPreferencePatch } from '../types/memory-preference.types'; + +@Injectable() +export class MemoryPreferenceService { + private readonly logger = new Logger(MemoryPreferenceService.name); + + constructor(private readonly repo: MemoryPreferenceRepository) {} + + async get(userId: string): Promise { + this.logger.debug(`get: userId=${userId}`); + const existing = await this.repo.findByUserId(userId); + if (existing) { + return existing; + } + return { + userId, + ...DEFAULT_MEMORY_PREFERENCE, + updatedAt: new Date(), + }; + } + + async upsert(userId: string, patch: MemoryPreferencePatch): Promise { + this.logger.log(`upsert: userId=${userId} keys=${Object.keys(patch).join(',')}`); + return this.repo.upsert(userId, patch); + } +} diff --git a/apps/claw-memory-service/src/modules/memory-preferences/types/memory-preference.types.ts b/apps/claw-memory-service/src/modules/memory-preferences/types/memory-preference.types.ts new file mode 100644 index 00000000..ae850b41 --- /dev/null +++ b/apps/claw-memory-service/src/modules/memory-preferences/types/memory-preference.types.ts @@ -0,0 +1,17 @@ +import type { MemoryRetention } from '../../../generated/prisma'; + +export type MemoryPreferencePatch = { + pausedAll?: boolean; + autoApproveThreshold?: number; + defaultRetention?: MemoryRetention; + defaultExpiresInDays?: number | null; + redactByDefault?: boolean; +}; + +export type MemoryPreferenceDefaults = { + pausedAll: boolean; + autoApproveThreshold: number; + defaultRetention: MemoryRetention; + defaultExpiresInDays: number | null; + redactByDefault: boolean; +}; diff --git a/apps/claw-memory-service/src/modules/memory-suggestions/controllers/memory-suggestions.controller.ts b/apps/claw-memory-service/src/modules/memory-suggestions/controllers/memory-suggestions.controller.ts new file mode 100644 index 00000000..08b599c8 --- /dev/null +++ b/apps/claw-memory-service/src/modules/memory-suggestions/controllers/memory-suggestions.controller.ts @@ -0,0 +1,64 @@ +import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common'; +import type { MemoryRecord, MemorySuggestion } from '../../../generated/prisma'; +import { ZodValidationPipe } from '../../../app/pipes/zod-validation.pipe'; +import { CurrentUser } from '../../../app/decorators/current-user.decorator'; +import type { AuthenticatedUser, PaginatedResult } from '../../../common/types'; +import { MemorySuggestionService } from '../services/memory-suggestion.service'; +import { type ApproveSuggestionDto, approveSuggestionSchema } from '../dto/approve-suggestion.dto'; +import { + type BulkApproveSuggestionsDto, + bulkApproveSuggestionsSchema, +} from '../dto/bulk-approve-suggestions.dto'; +import { + type ListMemorySuggestionsQueryDto, + listMemorySuggestionsQuerySchema, +} from '../dto/list-memory-suggestions-query.dto'; +import { type RejectSuggestionDto, rejectSuggestionSchema } from '../dto/reject-suggestion.dto'; + +@Controller('memory-suggestions') +export class MemorySuggestionsController { + constructor(private readonly service: MemorySuggestionService) {} + + @Get() + async list( + @CurrentUser() user: AuthenticatedUser, + @Query(new ZodValidationPipe(listMemorySuggestionsQuerySchema)) + query: ListMemorySuggestionsQueryDto, + ): Promise> { + return this.service.list(user.id, query); + } + + @Post(':id/approve') + async approve( + @Param('id') id: string, + @CurrentUser() user: AuthenticatedUser, + @Body(new ZodValidationPipe(approveSuggestionSchema)) dto: ApproveSuggestionDto, + ): Promise { + return this.service.approve(id, user.id, dto); + } + + @Post(':id/reject') + async reject( + @Param('id') id: string, + @CurrentUser() user: AuthenticatedUser, + @Body(new ZodValidationPipe(rejectSuggestionSchema)) dto: RejectSuggestionDto, + ): Promise { + return this.service.reject(id, user.id, dto); + } + + @Delete(':id') + async dismiss( + @Param('id') id: string, + @CurrentUser() user: AuthenticatedUser, + ): Promise { + return this.service.dismiss(id, user.id); + } + + @Post('bulk-approve') + async bulkApprove( + @CurrentUser() user: AuthenticatedUser, + @Body(new ZodValidationPipe(bulkApproveSuggestionsSchema)) dto: BulkApproveSuggestionsDto, + ): Promise<{ approved: string[]; skipped: Array<{ suggestionId: string; reason: string }> }> { + return this.service.bulkApprove(dto.suggestionIds, user.id); + } +} diff --git a/apps/claw-memory-service/src/modules/memory-suggestions/dto/approve-suggestion.dto.ts b/apps/claw-memory-service/src/modules/memory-suggestions/dto/approve-suggestion.dto.ts new file mode 100644 index 00000000..5b1ca5b2 --- /dev/null +++ b/apps/claw-memory-service/src/modules/memory-suggestions/dto/approve-suggestion.dto.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; +import { MemoryRetention, MemoryScope } from '../../../generated/prisma'; + +export const approveSuggestionSchema = z.object({ + editedContent: z.string().min(1).max(50000).optional(), + scope: z.nativeEnum(MemoryScope).optional(), + scopeRef: z.string().max(255).optional(), + retentionPolicy: z.nativeEnum(MemoryRetention).optional(), + expiresAt: z.string().datetime().optional(), +}); + +export type ApproveSuggestionDto = z.infer; diff --git a/apps/claw-memory-service/src/modules/memory-suggestions/dto/bulk-approve-suggestions.dto.ts b/apps/claw-memory-service/src/modules/memory-suggestions/dto/bulk-approve-suggestions.dto.ts new file mode 100644 index 00000000..147d1b62 --- /dev/null +++ b/apps/claw-memory-service/src/modules/memory-suggestions/dto/bulk-approve-suggestions.dto.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const bulkApproveSuggestionsSchema = z.object({ + suggestionIds: z.array(z.string().min(1).max(64)).min(1).max(100), +}); + +export type BulkApproveSuggestionsDto = z.infer; diff --git a/apps/claw-memory-service/src/modules/memory-suggestions/dto/list-memory-suggestions-query.dto.ts b/apps/claw-memory-service/src/modules/memory-suggestions/dto/list-memory-suggestions-query.dto.ts new file mode 100644 index 00000000..4efc99f3 --- /dev/null +++ b/apps/claw-memory-service/src/modules/memory-suggestions/dto/list-memory-suggestions-query.dto.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import { MemorySuggestionStatus } from '../../../generated/prisma'; + +export const listMemorySuggestionsQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(20), + status: z.nativeEnum(MemorySuggestionStatus).optional(), +}); + +export type ListMemorySuggestionsQueryDto = z.infer; diff --git a/apps/claw-memory-service/src/modules/memory-suggestions/dto/reject-suggestion.dto.ts b/apps/claw-memory-service/src/modules/memory-suggestions/dto/reject-suggestion.dto.ts new file mode 100644 index 00000000..884c693d --- /dev/null +++ b/apps/claw-memory-service/src/modules/memory-suggestions/dto/reject-suggestion.dto.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const rejectSuggestionSchema = z.object({ + reason: z.string().max(255).optional(), + suppressSimilar: z.boolean().default(false), +}); + +export type RejectSuggestionDto = z.infer; diff --git a/apps/claw-memory-service/src/modules/memory-suggestions/repositories/memory-suggestion.repository.ts b/apps/claw-memory-service/src/modules/memory-suggestions/repositories/memory-suggestion.repository.ts new file mode 100644 index 00000000..684a9b29 --- /dev/null +++ b/apps/claw-memory-service/src/modules/memory-suggestions/repositories/memory-suggestion.repository.ts @@ -0,0 +1,89 @@ +import { Injectable } from '@nestjs/common'; +import { + type MemorySuggestion, + MemorySuggestionStatus, + type Prisma, +} from '../../../generated/prisma'; +import { PrismaService } from '../../../infrastructure/database/prisma/prisma.service'; +import type { + CreateSuggestionData, + DecideSuggestionData, + SuggestionFilters, +} from '../types/memory-suggestion.types'; + +@Injectable() +export class MemorySuggestionRepository { + constructor(private readonly prisma: PrismaService) {} + + async create(data: CreateSuggestionData): Promise { + return this.prisma.memorySuggestion.create({ + data: { + userId: data.userId, + type: data.type, + content: data.content, + confidence: data.confidence, + sensitivity: data.sensitivity, + reason: data.reason ?? null, + sourceThreadId: data.sourceThreadId ?? null, + sourceMessageId: data.sourceMessageId ?? null, + }, + }); + } + + async findById(id: string): Promise { + return this.prisma.memorySuggestion.findUnique({ where: { id } }); + } + + async findAll( + filters: SuggestionFilters, + page: number, + limit: number, + ): Promise { + const where = this.buildWhere(filters); + return this.prisma.memorySuggestion.findMany({ + where, + skip: (page - 1) * limit, + take: limit, + orderBy: { createdAt: 'desc' }, + }); + } + + async countAll(filters: SuggestionFilters): Promise { + return this.prisma.memorySuggestion.count({ where: this.buildWhere(filters) }); + } + + async findByIds(ids: string[]): Promise { + if (ids.length === 0) { + return []; + } + return this.prisma.memorySuggestion.findMany({ where: { id: { in: ids } } }); + } + + async decide(id: string, data: DecideSuggestionData): Promise { + return this.prisma.memorySuggestion.update({ + where: { id }, + data: { + status: data.status, + decidedAt: new Date(), + decidedBy: data.decidedBy, + resultingMemoryId: data.resultingMemoryId ?? null, + }, + }); + } + + async expireOlderThan(date: Date): Promise { + const result = await this.prisma.memorySuggestion.updateMany({ + where: { status: MemorySuggestionStatus.PENDING, createdAt: { lt: date } }, + data: { status: MemorySuggestionStatus.EXPIRED, decidedAt: new Date(), decidedBy: 'system' }, + }); + return result.count; + } + + private buildWhere(filters: SuggestionFilters): Prisma.MemorySuggestionWhereInput { + const where: Prisma.MemorySuggestionWhereInput = { userId: filters.userId }; + if (filters.status !== undefined) { + where.status = filters.status; + } + return where; + } +} diff --git a/apps/claw-memory-service/src/modules/memory-suggestions/services/memory-suggestion.service.ts b/apps/claw-memory-service/src/modules/memory-suggestions/services/memory-suggestion.service.ts new file mode 100644 index 00000000..7669f137 --- /dev/null +++ b/apps/claw-memory-service/src/modules/memory-suggestions/services/memory-suggestion.service.ts @@ -0,0 +1,226 @@ +import { HttpStatus, Injectable, Logger } from '@nestjs/common'; +import { RabbitMQService } from '@claw/shared-rabbitmq'; +import { EventPattern } from '@claw/shared-types'; +import { + MemoryAuditAction, + type MemoryRecord, + MemorySensitivity, + MemorySource, + type MemorySuggestion, + MemorySuggestionStatus, +} from '../../../generated/prisma'; +import { BusinessException, EntityNotFoundException } from '../../../common/errors'; +import type { PaginatedResult } from '../../../common/types'; +import { MemoryAuditService } from '../../memory-audit/services/memory-audit.service'; +import { MemoryRepository } from '../../memory/repositories/memory.repository'; +import { MemorySuggestionRepository } from '../repositories/memory-suggestion.repository'; +import type { ApproveSuggestionDto } from '../dto/approve-suggestion.dto'; +import type { ListMemorySuggestionsQueryDto } from '../dto/list-memory-suggestions-query.dto'; +import type { RejectSuggestionDto } from '../dto/reject-suggestion.dto'; +import type { BulkApprovalResult } from '../types/memory-suggestion.types'; + +@Injectable() +export class MemorySuggestionService { + private readonly logger = new Logger(MemorySuggestionService.name); + + constructor( + private readonly suggestionRepo: MemorySuggestionRepository, + private readonly memoryRepo: MemoryRepository, + private readonly auditService: MemoryAuditService, + private readonly rabbitMQService: RabbitMQService, + ) {} + + async list( + userId: string, + query: ListMemorySuggestionsQueryDto, + ): Promise> { + this.logger.debug( + `list: userId=${userId} status=${query.status ?? 'any'} page=${String(query.page)}`, + ); + const filters = { userId, status: query.status }; + const [data, total] = await Promise.all([ + this.suggestionRepo.findAll(filters, query.page, query.limit), + this.suggestionRepo.countAll(filters), + ]); + return { + data, + meta: { + total, + page: query.page, + limit: query.limit, + totalPages: Math.ceil(total / query.limit), + }, + }; + } + + async approve( + suggestionId: string, + userId: string, + dto: ApproveSuggestionDto, + ): Promise { + this.logger.log(`approve: suggestionId=${suggestionId} userId=${userId}`); + const suggestion = await this.assertOwnedSuggestion(suggestionId, userId); + if (suggestion.status === MemorySuggestionStatus.APPROVED && suggestion.resultingMemoryId) { + const existing = await this.memoryRepo.findById(suggestion.resultingMemoryId); + if (existing) { + return existing; + } + } + if (this.isDecided(suggestion)) { + throw new BusinessException( + 'Suggestion already decided', + 'SUGGESTION_ALREADY_DECIDED', + HttpStatus.CONFLICT, + ); + } + if (suggestion.sensitivity === MemorySensitivity.REDACTED && dto.editedContent === undefined) { + throw new BusinessException( + 'Edit required to approve a redacted suggestion', + 'REDACTED_REQUIRES_EDIT', + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + const memory = await this.memoryRepo.create({ + userId, + type: suggestion.type, + content: dto.editedContent ?? suggestion.content, + sourceThreadId: suggestion.sourceThreadId ?? undefined, + sourceMessageId: suggestion.sourceMessageId ?? undefined, + scope: dto.scope, + scopeRef: dto.scopeRef, + retentionPolicy: dto.retentionPolicy, + expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : undefined, + source: MemorySource.AI_EXTRACTED, + sensitivity: suggestion.sensitivity, + confidence: suggestion.confidence, + provenanceJson: { suggestionId }, + }); + await this.suggestionRepo.decide(suggestionId, { + status: MemorySuggestionStatus.APPROVED, + decidedBy: userId, + resultingMemoryId: memory.id, + }); + await this.auditService.record({ + userId, + memoryId: memory.id, + action: MemoryAuditAction.APPROVED, + actor: userId, + details: { suggestionId }, + }); + void this.rabbitMQService.publish(EventPattern.MEMORY_APPROVED, { + memoryId: memory.id, + suggestionId, + userId, + automated: false, + timestamp: new Date().toISOString(), + }); + return memory; + } + + async reject( + suggestionId: string, + userId: string, + dto: RejectSuggestionDto, + ): Promise { + this.logger.log(`reject: suggestionId=${suggestionId} userId=${userId}`); + const suggestion = await this.assertOwnedSuggestion(suggestionId, userId); + if (this.isDecided(suggestion)) { + throw new BusinessException( + 'Suggestion already decided', + 'SUGGESTION_ALREADY_DECIDED', + HttpStatus.CONFLICT, + ); + } + const decided = await this.suggestionRepo.decide(suggestionId, { + status: MemorySuggestionStatus.REJECTED, + decidedBy: userId, + }); + await this.auditService.record({ + userId, + action: MemoryAuditAction.REJECTED, + actor: userId, + details: { + suggestionId, + reason: dto.reason ?? null, + suppressSimilar: dto.suppressSimilar, + }, + }); + void this.rabbitMQService.publish(EventPattern.MEMORY_REJECTED, { + suggestionId, + userId, + reason: dto.reason ?? null, + suppressSimilar: dto.suppressSimilar, + timestamp: new Date().toISOString(), + }); + return decided; + } + + async dismiss(suggestionId: string, userId: string): Promise { + this.logger.log(`dismiss: suggestionId=${suggestionId} userId=${userId}`); + const suggestion = await this.assertOwnedSuggestion(suggestionId, userId); + if (this.isDecided(suggestion)) { + return suggestion; + } + return this.suggestionRepo.decide(suggestionId, { + status: MemorySuggestionStatus.DISMISSED, + decidedBy: userId, + }); + } + + async bulkApprove(suggestionIds: string[], userId: string): Promise { + this.logger.log(`bulkApprove: userId=${userId} count=${String(suggestionIds.length)}`); + const suggestions = await this.suggestionRepo.findByIds(suggestionIds); + const approved: string[] = []; + const skipped: BulkApprovalResult['skipped'] = []; + for (const s of suggestions) { + if (s.userId !== userId) { + skipped.push({ suggestionId: s.id, reason: 'FORBIDDEN' }); + continue; + } + if (this.isDecided(s)) { + skipped.push({ suggestionId: s.id, reason: 'ALREADY_DECIDED' }); + continue; + } + if (s.sensitivity !== MemorySensitivity.NORMAL) { + skipped.push({ suggestionId: s.id, reason: 'SENSITIVE_REQUIRES_REVIEW' }); + continue; + } + try { + await this.approve(s.id, userId, {}); + approved.push(s.id); + } catch (error) { + const msg = error instanceof Error ? error.message : 'unknown'; + skipped.push({ suggestionId: s.id, reason: msg }); + } + } + return { approved, skipped }; + } + + private async assertOwnedSuggestion( + suggestionId: string, + userId: string, + ): Promise { + const suggestion = await this.suggestionRepo.findById(suggestionId); + if (!suggestion) { + throw new EntityNotFoundException('MemorySuggestion', suggestionId); + } + if (suggestion.userId !== userId) { + throw new BusinessException( + 'You do not have access to this suggestion', + 'FORBIDDEN_SUGGESTION_ACCESS', + HttpStatus.FORBIDDEN, + ); + } + return suggestion; + } + + private isDecided(s: MemorySuggestion): boolean { + return ( + s.status === MemorySuggestionStatus.APPROVED || + s.status === MemorySuggestionStatus.REJECTED || + s.status === MemorySuggestionStatus.AUTO_APPROVED || + s.status === MemorySuggestionStatus.DISMISSED || + s.status === MemorySuggestionStatus.EXPIRED + ); + } +} diff --git a/apps/claw-memory-service/src/modules/memory-suggestions/types/memory-suggestion.types.ts b/apps/claw-memory-service/src/modules/memory-suggestions/types/memory-suggestion.types.ts new file mode 100644 index 00000000..f1870f02 --- /dev/null +++ b/apps/claw-memory-service/src/modules/memory-suggestions/types/memory-suggestion.types.ts @@ -0,0 +1,32 @@ +import type { + MemorySensitivity, + MemorySuggestionStatus, + MemoryType, +} from '../../../generated/prisma'; + +export type CreateSuggestionData = { + userId: string; + type: MemoryType; + content: string; + confidence: number; + sensitivity: MemorySensitivity; + reason?: string | null; + sourceThreadId?: string | null; + sourceMessageId?: string | null; +}; + +export type SuggestionFilters = { + userId: string; + status?: MemorySuggestionStatus; +}; + +export type DecideSuggestionData = { + status: MemorySuggestionStatus; + decidedBy: string; + resultingMemoryId?: string | null; +}; + +export type BulkApprovalResult = { + approved: string[]; + skipped: Array<{ suggestionId: string; reason: string }>; +}; diff --git a/apps/claw-memory-service/src/modules/memory-usage/controllers/memory-usage.controller.ts b/apps/claw-memory-service/src/modules/memory-usage/controllers/memory-usage.controller.ts new file mode 100644 index 00000000..a235aea1 --- /dev/null +++ b/apps/claw-memory-service/src/modules/memory-usage/controllers/memory-usage.controller.ts @@ -0,0 +1,28 @@ +import { Controller, Get, Param, Query } from '@nestjs/common'; +import type { MemoryUsage } from '../../../generated/prisma'; +import { CurrentUser } from '../../../app/decorators/current-user.decorator'; +import type { AuthenticatedUser } from '../../../common/types'; +import { MemoryUsageService } from '../services/memory-usage.service'; + +@Controller('memory-usage') +export class MemoryUsageController { + constructor(private readonly service: MemoryUsageService) {} + + @Get('by-memory/:memoryId') + async byMemory( + @Param('memoryId') memoryId: string, + @CurrentUser() user: AuthenticatedUser, + @Query('limit') limit: string | undefined, + ): Promise { + const parsed = Math.min(Math.max(Number(limit) || 50, 1), 200); + return this.service.getByMemoryId(memoryId, user.id, parsed); + } + + @Get('by-message/:messageId') + async byMessage( + @Param('messageId') messageId: string, + @CurrentUser() user: AuthenticatedUser, + ): Promise { + return this.service.getByMessageId(messageId, user.id); + } +} diff --git a/apps/claw-memory-service/src/modules/memory-usage/repositories/memory-usage.repository.ts b/apps/claw-memory-service/src/modules/memory-usage/repositories/memory-usage.repository.ts new file mode 100644 index 00000000..8591958a --- /dev/null +++ b/apps/claw-memory-service/src/modules/memory-usage/repositories/memory-usage.repository.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; +import type { MemoryUsage } from '../../../generated/prisma'; +import { PrismaService } from '../../../infrastructure/database/prisma/prisma.service'; +import type { WriteMemoryUsageData } from '../types/memory-usage.types'; + +export type { WriteMemoryUsageData }; + +@Injectable() +export class MemoryUsageRepository { + constructor(private readonly prisma: PrismaService) {} + + async writeMany(rows: WriteMemoryUsageData[]): Promise { + if (rows.length === 0) { + return 0; + } + const result = await this.prisma.memoryUsage.createMany({ + data: rows.map((r) => ({ + memoryId: r.memoryId, + userId: r.userId, + threadId: r.threadId, + messageId: r.messageId, + score: r.score, + reason: r.reason ?? null, + })), + }); + return result.count; + } + + async findByMemoryId(memoryId: string, limit = 50): Promise { + return this.prisma.memoryUsage.findMany({ + where: { memoryId }, + orderBy: { createdAt: 'desc' }, + take: limit, + }); + } + + async findByMessageId(messageId: string): Promise { + return this.prisma.memoryUsage.findMany({ where: { messageId } }); + } +} diff --git a/apps/claw-memory-service/src/modules/memory-usage/services/memory-usage.service.ts b/apps/claw-memory-service/src/modules/memory-usage/services/memory-usage.service.ts new file mode 100644 index 00000000..7708cd46 --- /dev/null +++ b/apps/claw-memory-service/src/modules/memory-usage/services/memory-usage.service.ts @@ -0,0 +1,31 @@ +import { Injectable, Logger } from '@nestjs/common'; +import type { MemoryUsage } from '../../../generated/prisma'; +import { + MemoryUsageRepository, + type WriteMemoryUsageData, +} from '../repositories/memory-usage.repository'; + +@Injectable() +export class MemoryUsageService { + private readonly logger = new Logger(MemoryUsageService.name); + + constructor(private readonly repo: MemoryUsageRepository) {} + + async record(rows: WriteMemoryUsageData[]): Promise { + if (rows.length === 0) { + return 0; + } + this.logger.debug(`record: writing ${String(rows.length)} usage row(s)`); + return this.repo.writeMany(rows); + } + + async getByMemoryId(memoryId: string, userId: string, limit?: number): Promise { + const rows = await this.repo.findByMemoryId(memoryId, limit); + return rows.filter((row) => row.userId === userId); + } + + async getByMessageId(messageId: string, userId: string): Promise { + const rows = await this.repo.findByMessageId(messageId); + return rows.filter((row) => row.userId === userId); + } +} diff --git a/apps/claw-memory-service/src/modules/memory-usage/types/memory-usage.types.ts b/apps/claw-memory-service/src/modules/memory-usage/types/memory-usage.types.ts new file mode 100644 index 00000000..719c2231 --- /dev/null +++ b/apps/claw-memory-service/src/modules/memory-usage/types/memory-usage.types.ts @@ -0,0 +1,8 @@ +export type WriteMemoryUsageData = { + memoryId: string; + userId: string; + threadId: string; + messageId: string; + score: number; + reason?: string | null; +}; diff --git a/apps/claw-memory-service/src/modules/memory/__tests__/memory.service.spec.ts b/apps/claw-memory-service/src/modules/memory/__tests__/memory.service.spec.ts index bf8e6cd9..7596bdb9 100644 --- a/apps/claw-memory-service/src/modules/memory/__tests__/memory.service.spec.ts +++ b/apps/claw-memory-service/src/modules/memory/__tests__/memory.service.spec.ts @@ -1,275 +1,137 @@ +import { + MemoryAuditAction, + type MemoryRecord, + MemoryRetention, + MemoryScope, + MemorySensitivity, + MemorySource, + MemoryType, +} from '../../../generated/prisma'; import { MemoryService } from '../services/memory.service'; import { type MemoryRepository } from '../repositories/memory.repository'; -import { type RabbitMQService } from '@claw/shared-rabbitmq'; -import { EventPattern } from '@claw/shared-types'; -import { BusinessException, EntityNotFoundException } from '../../../common/errors'; -import { MemoryType } from '../../../generated/prisma'; - -const mockMemory = { - id: 'mem-1', - userId: 'user-1', - type: MemoryType.FACT, - content: 'The user prefers dark mode', - sourceThreadId: null, - sourceMessageId: null, - isEnabled: true, - createdAt: new Date(), - updatedAt: new Date(), -}; - -const mockMemoryRepository = (): Record => ({ - create: jest.fn(), - findById: jest.fn(), - findAll: jest.fn(), - findEnabledByUserId: jest.fn(), - findLearnedPreferences: jest.fn(), - existsSimilar: jest.fn().mockResolvedValue(false), - update: jest.fn(), - delete: jest.fn(), - countAll: jest.fn(), -}); - -const mockRabbitMQ = (): Partial> => ({ - publish: jest.fn().mockResolvedValue(void 0), - subscribe: jest.fn().mockResolvedValue(void 0), -}); - -describe('MemoryService', () => { +import { type MemoryExtractionManager } from '../managers/memory-extraction.manager'; +import { MemorySensitivityManager } from '../managers/memory-sensitivity.manager'; +import { type MemorySuggestionRepository } from '../../memory-suggestions/repositories/memory-suggestion.repository'; +import { type MemoryAuditService } from '../../memory-audit/services/memory-audit.service'; +import { type MemoryPreferenceService } from '../../memory-preferences/services/memory-preference.service'; +import type { CreateMemoryDto } from '../dto/create-memory.dto'; + +function makeStub(): T { + const cache: Record = {}; + return new Proxy({} as T, { + get: (_target, prop) => { + if (!cache[prop]) { + cache[prop] = jest.fn(); + } + return cache[prop]; + }, + }); +} + +function buildMemoryRecord(overrides: Partial = {}): MemoryRecord { + return { + id: 'mem-1', + userId: 'user-1', + type: MemoryType.FACT, + content: 'Pretend memory content', + sourceThreadId: null, + sourceMessageId: null, + isEnabled: true, + scope: MemoryScope.USER, + scopeRef: null, + tags: [], + category: null, + priority: 50, + confidence: 1, + source: MemorySource.USER_MANUAL, + sensitivity: MemorySensitivity.NORMAL, + retentionPolicy: MemoryRetention.PERMANENT, + expiresAt: null, + pinned: false, + pausedUntil: null, + qualityScore: 0.5, + useCount: 0, + lastUsedAt: null, + provenanceJson: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +describe('MemoryService (V2)', () => { + let memoryRepo: MemoryRepository; + let extraction: MemoryExtractionManager; + let sensitivity: MemorySensitivityManager; + let suggestionRepo: MemorySuggestionRepository; + let auditService: MemoryAuditService; + let preferenceService: MemoryPreferenceService; + let rabbit: { publish: ReturnType; subscribe: ReturnType }; let service: MemoryService; - let memoryRepo: ReturnType; - let rabbitMQ: ReturnType; beforeEach(() => { - memoryRepo = mockMemoryRepository(); - rabbitMQ = mockRabbitMQ(); - const extractionManager = { extract: jest.fn().mockResolvedValue(void 0) }; + memoryRepo = makeStub(); + extraction = makeStub(); + sensitivity = new MemorySensitivityManager(); + suggestionRepo = makeStub(); + auditService = makeStub(); + preferenceService = makeStub(); + rabbit = { publish: jest.fn(), subscribe: jest.fn() }; service = new MemoryService( - memoryRepo as unknown as MemoryRepository, - extractionManager as never, - rabbitMQ as unknown as RabbitMQService, + memoryRepo, + extraction, + sensitivity, + suggestionRepo, + auditService, + preferenceService, + rabbit as unknown as ConstructorParameters[6], ); }); - describe('createMemory', () => { - it('should create a memory and publish event', async () => { - memoryRepo.create.mockResolvedValue(mockMemory); - - const result = await service.createMemory('user-1', { - type: MemoryType.FACT, - content: 'The user prefers dark mode', - }); - - expect(result).toEqual(mockMemory); - expect(memoryRepo.create).toHaveBeenCalledWith({ - userId: 'user-1', - type: MemoryType.FACT, - content: 'The user prefers dark mode', - sourceThreadId: undefined, - sourceMessageId: undefined, - }); - expect(rabbitMQ.publish).toHaveBeenCalledWith( - EventPattern.MEMORY_EXTRACTED, - expect.objectContaining({ - memoryId: 'mem-1', - userId: 'user-1', - type: MemoryType.FACT, - }), - ); - }); - - it('should create memory with source references', async () => { - const memoryWithSource = { - ...mockMemory, - sourceThreadId: 'thread-1', - sourceMessageId: 'msg-1', - }; - memoryRepo.create.mockResolvedValue(memoryWithSource); - - const result = await service.createMemory('user-1', { - type: MemoryType.FACT, - content: 'The user prefers dark mode', - sourceThreadId: 'thread-1', - sourceMessageId: 'msg-1', - }); - - expect(result.sourceThreadId).toBe('thread-1'); - expect(result.sourceMessageId).toBe('msg-1'); - }); - }); - - describe('getMemories', () => { - it('should return paginated memories', async () => { - memoryRepo.findAll.mockResolvedValue([mockMemory]); - memoryRepo.countAll.mockResolvedValue(1); - - const result = await service.getMemories('user-1', { - page: 1, - limit: 20, - }); - - expect(result.data).toHaveLength(1); - expect(result.meta.total).toBe(1); - expect(result.meta.page).toBe(1); - expect(result.meta.totalPages).toBe(1); - }); - - it('should pass filters to repository', async () => { - memoryRepo.findAll.mockResolvedValue([]); - memoryRepo.countAll.mockResolvedValue(0); - - await service.getMemories('user-1', { - page: 1, - limit: 20, - type: MemoryType.PREFERENCE, - isEnabled: true, - search: 'dark', - }); - - expect(memoryRepo.findAll).toHaveBeenCalledWith( - { userId: 'user-1', type: MemoryType.PREFERENCE, isEnabled: true, search: 'dark' }, - 1, - 20, - ); - }); - - it('should calculate totalPages correctly', async () => { - memoryRepo.findAll.mockResolvedValue([mockMemory]); - memoryRepo.countAll.mockResolvedValue(45); - - const result = await service.getMemories('user-1', { - page: 1, - limit: 20, - }); - - expect(result.meta.totalPages).toBe(3); - }); - }); - - describe('getMemory', () => { - it('should return memory when found and owned by user', async () => { - memoryRepo.findById.mockResolvedValue(mockMemory); - - const result = await service.getMemory('mem-1', 'user-1'); - - expect(result).toEqual(mockMemory); - }); - - it('should throw EntityNotFoundException when not found', async () => { - memoryRepo.findById.mockResolvedValue(null); - - await expect(service.getMemory('nonexistent', 'user-1')).rejects.toThrow( - EntityNotFoundException, - ); - }); - - it('should throw BusinessException when user does not own memory', async () => { - memoryRepo.findById.mockResolvedValue(mockMemory); - - await expect(service.getMemory('mem-1', 'other-user')).rejects.toThrow(BusinessException); - }); - }); - - describe('updateMemory', () => { - it('should update memory successfully', async () => { - const updated = { ...mockMemory, content: 'Updated content' }; - memoryRepo.findById.mockResolvedValue(mockMemory); - memoryRepo.update.mockResolvedValue(updated); - - const result = await service.updateMemory('mem-1', 'user-1', { - content: 'Updated content', - }); - - expect(result.content).toBe('Updated content'); - expect(memoryRepo.update).toHaveBeenCalledWith('mem-1', { - content: 'Updated content', - isEnabled: undefined, - }); - }); - - it('should throw EntityNotFoundException when not found', async () => { - memoryRepo.findById.mockResolvedValue(null); - - await expect( - service.updateMemory('nonexistent', 'user-1', { content: 'New' }), - ).rejects.toThrow(EntityNotFoundException); - }); - - it('should throw BusinessException when user does not own memory', async () => { - memoryRepo.findById.mockResolvedValue(mockMemory); - - await expect(service.updateMemory('mem-1', 'other-user', { content: 'New' })).rejects.toThrow( - BusinessException, - ); - }); - }); - - describe('deleteMemory', () => { - it('should delete memory successfully', async () => { - memoryRepo.findById.mockResolvedValue(mockMemory); - memoryRepo.delete.mockResolvedValue(mockMemory); - - const result = await service.deleteMemory('mem-1', 'user-1'); + it('creates a normal memory and records audit', async () => { + const created = buildMemoryRecord(); + (memoryRepo.create as unknown as jest.Mock).mockResolvedValue(created); - expect(result).toEqual(mockMemory); - expect(memoryRepo.delete).toHaveBeenCalledWith('mem-1'); - }); + const dto: CreateMemoryDto = { + type: MemoryType.FACT, + content: 'My favourite colour is blue.', + }; - it('should throw EntityNotFoundException when not found', async () => { - memoryRepo.findById.mockResolvedValue(null); - - await expect(service.deleteMemory('nonexistent', 'user-1')).rejects.toThrow( - EntityNotFoundException, - ); - }); + const result = await service.createMemory('user-1', dto); - it('should throw BusinessException when user does not own memory', async () => { - memoryRepo.findById.mockResolvedValue(mockMemory); - - await expect(service.deleteMemory('mem-1', 'other-user')).rejects.toThrow(BusinessException); - }); + expect(result).toEqual(created); + expect(memoryRepo.create).toHaveBeenCalled(); + expect(auditService.record).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + action: MemoryAuditAction.CREATED, + }), + ); }); - describe('toggleMemory', () => { - it('should toggle enabled memory to disabled', async () => { - const toggled = { ...mockMemory, isEnabled: false }; - memoryRepo.findById.mockResolvedValue(mockMemory); - memoryRepo.update.mockResolvedValue(toggled); - - const result = await service.toggleMemory('mem-1', 'user-1'); - - expect(result.isEnabled).toBe(false); - expect(memoryRepo.update).toHaveBeenCalledWith('mem-1', { isEnabled: false }); + it('redacts content when sensitivity classifier finds an AWS key', async () => { + const created = buildMemoryRecord({ + content: 'AK********0000', + sensitivity: MemorySensitivity.REDACTED, }); + (memoryRepo.create as unknown as jest.Mock).mockResolvedValue(created); - it('should toggle disabled memory to enabled', async () => { - const disabledMemory = { ...mockMemory, isEnabled: false }; - const toggled = { ...mockMemory, isEnabled: true }; - memoryRepo.findById.mockResolvedValue(disabledMemory); - memoryRepo.update.mockResolvedValue(toggled); - - const result = await service.toggleMemory('mem-1', 'user-1'); - - expect(result.isEnabled).toBe(true); - expect(memoryRepo.update).toHaveBeenCalledWith('mem-1', { isEnabled: true }); - }); + const dto: CreateMemoryDto = { + type: MemoryType.FACT, + content: 'My AWS key is AKIA1234567890ABCDEF', + }; - it('should throw EntityNotFoundException when not found', async () => { - memoryRepo.findById.mockResolvedValue(null); + const result = await service.createMemory('user-1', dto); - await expect(service.toggleMemory('nonexistent', 'user-1')).rejects.toThrow( - EntityNotFoundException, - ); - }); + expect(result.sensitivity).toBe(MemorySensitivity.REDACTED); + expect(auditService.record).toHaveBeenCalledWith( + expect.objectContaining({ action: MemoryAuditAction.REDACTED }), + ); }); - describe('onModuleInit', () => { - it('should subscribe to message.completed event', async () => { - await service.onModuleInit(); - - expect(rabbitMQ.subscribe).toHaveBeenCalledWith( - EventPattern.MESSAGE_COMPLETED, - expect.any(Function), - ); + it('blocks forget without confirmation', async () => { + await expect(service.deleteMemory('mem-1', 'user-1', false)).rejects.toMatchObject({ + code: 'FORGET_CONFIRMATION_REQUIRED', }); }); }); diff --git a/apps/claw-memory-service/src/modules/memory/controllers/__tests__/memory.controller.spec.ts b/apps/claw-memory-service/src/modules/memory/controllers/__tests__/memory.controller.spec.ts index d09c5e66..2b1d44b4 100644 --- a/apps/claw-memory-service/src/modules/memory/controllers/__tests__/memory.controller.spec.ts +++ b/apps/claw-memory-service/src/modules/memory/controllers/__tests__/memory.controller.spec.ts @@ -53,9 +53,9 @@ describe('MemoryController', () => { expect(serviceMock.updateMemory).toHaveBeenCalledWith('m1', 'u1', { content: 'new' }); }); - it('remove forwards id and user.id', async () => { - await controller.remove('m1', user as never); - expect(serviceMock.deleteMemory).toHaveBeenCalledWith('m1', 'u1'); + it('remove forwards id, user.id and confirm flag', async () => { + await controller.remove('m1', user as never, 'FORGET'); + expect(serviceMock.deleteMemory).toHaveBeenCalledWith('m1', 'u1', true); }); it('toggle forwards id and user.id', async () => { diff --git a/apps/claw-memory-service/src/modules/memory/controllers/memory-retrieval.controller.ts b/apps/claw-memory-service/src/modules/memory/controllers/memory-retrieval.controller.ts new file mode 100644 index 00000000..9cc1e10c --- /dev/null +++ b/apps/claw-memory-service/src/modules/memory/controllers/memory-retrieval.controller.ts @@ -0,0 +1,32 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { type RetrievalBundle, type RetrievalRequest } from '@claw/shared-types'; +import { Public } from '../../../app/decorators/public.decorator'; +import { ZodValidationPipe } from '../../../app/pipes/zod-validation.pipe'; +import { + type RecordUsageRequestDto, + recordUsageRequestSchema, + retrieveRequestSchema, +} from '../dto/retrieve.dto'; +import { MemoryRetrievalService } from '../services/memory-retrieval.service'; + +@Controller('internal/memories') +export class MemoryRetrievalController { + constructor(private readonly retrieval: MemoryRetrievalService) {} + + @Public() + @Post('retrieve') + async retrieve( + @Body(new ZodValidationPipe(retrieveRequestSchema)) body: RetrievalRequest, + ): Promise { + return this.retrieval.retrieve(body); + } + + @Public() + @Post('record-usage') + async recordUsage( + @Body(new ZodValidationPipe(recordUsageRequestSchema)) body: RecordUsageRequestDto, + ): Promise<{ recorded: number }> { + const recorded = await this.retrieval.recordUsage(body.rows); + return { recorded }; + } +} diff --git a/apps/claw-memory-service/src/modules/memory/controllers/memory.controller.ts b/apps/claw-memory-service/src/modules/memory/controllers/memory.controller.ts index d6caaad4..d77fe5c9 100644 --- a/apps/claw-memory-service/src/modules/memory/controllers/memory.controller.ts +++ b/apps/claw-memory-service/src/modules/memory/controllers/memory.controller.ts @@ -1,14 +1,15 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from "@nestjs/common"; -import { type MemoryRecord } from "../../../generated/prisma"; -import { ZodValidationPipe } from "../../../app/pipes/zod-validation.pipe"; -import { CurrentUser } from "../../../app/decorators/current-user.decorator"; -import { type AuthenticatedUser, type PaginatedResult } from "../../../common/types"; -import { MemoryService } from "../services/memory.service"; -import { type CreateMemoryDto, createMemorySchema } from "../dto/create-memory.dto"; -import { type UpdateMemoryDto, updateMemorySchema } from "../dto/update-memory.dto"; -import { type ListMemoriesQueryDto, listMemoriesQuerySchema } from "../dto/list-memories-query.dto"; - -@Controller("memories") +import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; +import { type MemoryRecord } from '../../../generated/prisma'; +import { ZodValidationPipe } from '../../../app/pipes/zod-validation.pipe'; +import { CurrentUser } from '../../../app/decorators/current-user.decorator'; +import { type AuthenticatedUser, type PaginatedResult } from '../../../common/types'; +import { MemoryService } from '../services/memory.service'; +import { type CreateMemoryDto, createMemorySchema } from '../dto/create-memory.dto'; +import { type UpdateMemoryDto, updateMemorySchema } from '../dto/update-memory.dto'; +import { type ListMemoriesQueryDto, listMemoriesQuerySchema } from '../dto/list-memories-query.dto'; +import { type SearchMemoriesDto, searchMemoriesSchema } from '../dto/search-memories.dto'; + +@Controller('memories') export class MemoryController { constructor(private readonly memoryService: MemoryService) {} @@ -28,34 +29,57 @@ export class MemoryController { return this.memoryService.getMemories(user.id, query); } - @Get(":id") + @Post('search') + async search( + @CurrentUser() user: AuthenticatedUser, + @Body(new ZodValidationPipe(searchMemoriesSchema)) dto: SearchMemoriesDto, + ): Promise> { + return this.memoryService.getMemories(user.id, { + page: 1, + limit: dto.limit, + type: undefined, + isEnabled: dto.includeDisabled ? undefined : true, + search: dto.query, + scope: dto.scope, + scopeRef: dto.scopeRef, + source: undefined, + sensitivity: undefined, + tag: undefined, + category: undefined, + pinnedOnly: undefined, + sort: 'newest', + }); + } + + @Get(':id') async findOne( - @Param("id") id: string, + @Param('id') id: string, @CurrentUser() user: AuthenticatedUser, ): Promise { return this.memoryService.getMemory(id, user.id); } - @Patch(":id") + @Patch(':id') async update( - @Param("id") id: string, + @Param('id') id: string, @CurrentUser() user: AuthenticatedUser, @Body(new ZodValidationPipe(updateMemorySchema)) dto: UpdateMemoryDto, ): Promise { return this.memoryService.updateMemory(id, user.id, dto); } - @Delete(":id") + @Delete(':id') async remove( - @Param("id") id: string, + @Param('id') id: string, @CurrentUser() user: AuthenticatedUser, + @Query('confirm') confirm: string | undefined, ): Promise { - return this.memoryService.deleteMemory(id, user.id); + return this.memoryService.deleteMemory(id, user.id, confirm === 'FORGET'); } - @Patch(":id/toggle") + @Patch(':id/toggle') async toggle( - @Param("id") id: string, + @Param('id') id: string, @CurrentUser() user: AuthenticatedUser, ): Promise { return this.memoryService.toggleMemory(id, user.id); diff --git a/apps/claw-memory-service/src/modules/memory/dto/create-memory.dto.ts b/apps/claw-memory-service/src/modules/memory/dto/create-memory.dto.ts index 3100b8f0..fe6bced5 100644 --- a/apps/claw-memory-service/src/modules/memory/dto/create-memory.dto.ts +++ b/apps/claw-memory-service/src/modules/memory/dto/create-memory.dto.ts @@ -1,11 +1,33 @@ -import { z } from "zod"; -import { MemoryType } from "../../../generated/prisma"; +import { z } from 'zod'; +import { + MemoryRetention, + MemoryScope, + MemorySensitivity, + MemorySource, + MemoryType, +} from '../../../generated/prisma'; export const createMemorySchema = z.object({ type: z.nativeEnum(MemoryType), - content: z.string().min(1, "Content is required").max(50000, "Content must be at most 50000 characters"), - sourceThreadId: z.string().max(255, "Source thread ID must be at most 255 characters").optional(), - sourceMessageId: z.string().max(255, "Source message ID must be at most 255 characters").optional(), + content: z + .string() + .min(1, 'Content is required') + .max(50000, 'Content must be at most 50000 characters'), + sourceThreadId: z.string().max(255).optional(), + sourceMessageId: z.string().max(255).optional(), + // V2 additions + scope: z.nativeEnum(MemoryScope).optional(), + scopeRef: z.string().max(255).optional(), + tags: z.array(z.string().min(1).max(64)).max(20).optional(), + category: z.string().max(64).optional(), + priority: z.number().int().min(0).max(100).optional(), + confidence: z.number().min(0).max(1).optional(), + source: z.nativeEnum(MemorySource).optional(), + sensitivity: z.nativeEnum(MemorySensitivity).optional(), + retentionPolicy: z.nativeEnum(MemoryRetention).optional(), + expiresAt: z.string().datetime().optional(), + pinned: z.boolean().optional(), + provenanceJson: z.record(z.unknown()).optional(), }); export type CreateMemoryDto = z.infer; diff --git a/apps/claw-memory-service/src/modules/memory/dto/list-memories-query.dto.ts b/apps/claw-memory-service/src/modules/memory/dto/list-memories-query.dto.ts index a7e3d47a..3a0f3ba6 100644 --- a/apps/claw-memory-service/src/modules/memory/dto/list-memories-query.dto.ts +++ b/apps/claw-memory-service/src/modules/memory/dto/list-memories-query.dto.ts @@ -1,15 +1,41 @@ -import { z } from "zod"; -import { MemoryType } from "../../../generated/prisma"; +import { z } from 'zod'; +import { + MemoryScope, + MemorySensitivity, + MemorySource, + MemoryType, +} from '../../../generated/prisma'; + +const MEMORY_SORT_VALUES = [ + 'newest', + 'oldest', + 'most_used', + 'lowest_confidence', + 'expiring_soon', +] as const; export const listMemoriesQuerySchema = z.object({ page: z.coerce.number().int().min(1).default(1), limit: z.coerce.number().int().min(1).max(100).default(20), type: z.nativeEnum(MemoryType).optional(), isEnabled: z - .enum(["true", "false"]) - .transform((val) => val === "true") + .enum(['true', 'false']) + .transform((val) => val === 'true') + .optional(), + search: z.string().max(255).optional(), + // V2 filters + scope: z.nativeEnum(MemoryScope).optional(), + scopeRef: z.string().max(255).optional(), + source: z.nativeEnum(MemorySource).optional(), + sensitivity: z.nativeEnum(MemorySensitivity).optional(), + tag: z.string().max(64).optional(), + category: z.string().max(64).optional(), + pinnedOnly: z + .enum(['true', 'false']) + .transform((val) => val === 'true') .optional(), - search: z.string().max(255, "Search must be at most 255 characters").optional(), + sort: z.enum(MEMORY_SORT_VALUES).default('newest'), }); export type ListMemoriesQueryDto = z.infer; +export type MemorySort = (typeof MEMORY_SORT_VALUES)[number]; diff --git a/apps/claw-memory-service/src/modules/memory/dto/retrieve.dto.ts b/apps/claw-memory-service/src/modules/memory/dto/retrieve.dto.ts new file mode 100644 index 00000000..33d68a6c --- /dev/null +++ b/apps/claw-memory-service/src/modules/memory/dto/retrieve.dto.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +export const retrieveRequestSchema = z.object({ + userId: z.string().min(1).max(255), + threadId: z.string().max(255).optional(), + workspaceId: z.string().max(255).optional(), + projectId: z.string().max(255).optional(), + intent: z.string().max(8192).default(''), + attachedPackIds: z.array(z.string().max(255)).max(20).default([]), + attachedMemoryIds: z.array(z.string().max(255)).max(40).default([]), + tokenBudget: z.number().int().min(64).max(200_000).default(4096), + includeMemory: z.boolean().default(true), + includeContext: z.boolean().default(true), + semanticBudgetMemory: z.number().int().min(0).max(50).optional(), + semanticBudgetContext: z.number().int().min(0).max(100).optional(), +}); + +export const recordUsageRequestSchema = z.object({ + rows: z + .array( + z.object({ + memoryId: z.string().min(1).max(255), + userId: z.string().min(1).max(255), + threadId: z.string().min(1).max(255), + messageId: z.string().min(1).max(255), + score: z.number().min(0).max(1), + reason: z.string().max(128), + }), + ) + .max(100), +}); + +export type RetrieveRequestDto = z.infer; +export type RecordUsageRequestDto = z.infer; diff --git a/apps/claw-memory-service/src/modules/memory/dto/search-memories.dto.ts b/apps/claw-memory-service/src/modules/memory/dto/search-memories.dto.ts new file mode 100644 index 00000000..b54f115d --- /dev/null +++ b/apps/claw-memory-service/src/modules/memory/dto/search-memories.dto.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; +import { MemoryScope } from '../../../generated/prisma'; + +export const searchMemoriesSchema = z.object({ + query: z.string().min(1).max(2048), + limit: z.coerce.number().int().min(1).max(50).default(10), + scope: z.nativeEnum(MemoryScope).optional(), + scopeRef: z.string().max(255).optional(), + includeDisabled: z.boolean().default(false), +}); + +export type SearchMemoriesDto = z.infer; diff --git a/apps/claw-memory-service/src/modules/memory/dto/update-memory.dto.ts b/apps/claw-memory-service/src/modules/memory/dto/update-memory.dto.ts index 92a52238..20469cfa 100644 --- a/apps/claw-memory-service/src/modules/memory/dto/update-memory.dto.ts +++ b/apps/claw-memory-service/src/modules/memory/dto/update-memory.dto.ts @@ -1,8 +1,19 @@ -import { z } from "zod"; +import { z } from 'zod'; +import { MemoryRetention, MemoryScope, MemorySensitivity } from '../../../generated/prisma'; export const updateMemorySchema = z.object({ - content: z.string().min(1, "Content is required").max(50000, "Content must be at most 50000 characters").optional(), + content: z.string().min(1).max(50000).optional(), isEnabled: z.boolean().optional(), + scope: z.nativeEnum(MemoryScope).optional(), + scopeRef: z.string().max(255).nullable().optional(), + tags: z.array(z.string().min(1).max(64)).max(20).optional(), + category: z.string().max(64).nullable().optional(), + priority: z.number().int().min(0).max(100).optional(), + retentionPolicy: z.nativeEnum(MemoryRetention).optional(), + expiresAt: z.string().datetime().nullable().optional(), + sensitivity: z.nativeEnum(MemorySensitivity).optional(), + pinned: z.boolean().optional(), + pausedUntil: z.string().datetime().nullable().optional(), }); export type UpdateMemoryDto = z.infer; diff --git a/apps/claw-memory-service/src/modules/memory/managers/memory-sensitivity.manager.ts b/apps/claw-memory-service/src/modules/memory/managers/memory-sensitivity.manager.ts new file mode 100644 index 00000000..24cc88d1 --- /dev/null +++ b/apps/claw-memory-service/src/modules/memory/managers/memory-sensitivity.manager.ts @@ -0,0 +1,57 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { MemorySensitivity } from '../../../generated/prisma'; +import { + SENSITIVITY_PRE_FILTER_PATTERNS, + SENSITIVITY_SOFT_HINTS, +} from '../../../common/constants/memory-sensitivity.constants'; +import type { SensitivityVerdict } from '../types/memory-sensitivity.types'; + +export type { SensitivityVerdict }; + +@Injectable() +export class MemorySensitivityManager { + private readonly logger = new Logger(MemorySensitivityManager.name); + + classify(content: string): SensitivityVerdict { + this.logger.debug(`classify: contentLen=${String(content.length)}`); + const trimmed = content.slice(0, 8192); + for (const { name, pattern } of SENSITIVITY_PRE_FILTER_PATTERNS) { + if (pattern.test(trimmed)) { + const redacted = this.redact(trimmed, pattern); + this.logger.warn(`classify: REDACTED — matched ${name}`); + return { + verdict: MemorySensitivity.REDACTED, + confidence: 1, + reason: name, + redactedPreview: redacted, + }; + } + } + const hits = SENSITIVITY_SOFT_HINTS.filter((hint) => trimmed.toLowerCase().includes(hint)); + if (hits.length > 0) { + this.logger.debug(`classify: SENSITIVE — soft hints=${hits.join(',')}`); + return { + verdict: MemorySensitivity.SENSITIVE, + confidence: Math.min(0.5 + 0.1 * hits.length, 0.9), + reason: `Mentions: ${hits.slice(0, 5).join(', ')}`, + redactedPreview: null, + }; + } + return { + verdict: MemorySensitivity.NORMAL, + confidence: 1, + reason: null, + redactedPreview: null, + }; + } + + private redact(content: string, pattern: RegExp): string { + const compact = content.replaceAll(pattern, (match) => { + if (match.length <= 4) { + return '*'.repeat(match.length); + } + return `${match.slice(0, 2)}${'*'.repeat(Math.max(match.length - 6, 4))}${match.slice(-4)}`; + }); + return compact.slice(0, 256); + } +} diff --git a/apps/claw-memory-service/src/modules/memory/memory.module.ts b/apps/claw-memory-service/src/modules/memory/memory.module.ts index c0844328..f37fa940 100644 --- a/apps/claw-memory-service/src/modules/memory/memory.module.ts +++ b/apps/claw-memory-service/src/modules/memory/memory.module.ts @@ -1,13 +1,50 @@ -import { Module } from "@nestjs/common"; -import { MemoryController } from "./controllers/memory.controller"; -import { MemoryInternalController } from "./controllers/memory-internal.controller"; -import { MemoryService } from "./services/memory.service"; -import { MemoryExtractionManager } from "./managers/memory-extraction.manager"; -import { MemoryRepository } from "./repositories/memory.repository"; +import { Module } from '@nestjs/common'; +import { MemoryController } from './controllers/memory.controller'; +import { MemoryInternalController } from './controllers/memory-internal.controller'; +import { MemoryRetrievalController } from './controllers/memory-retrieval.controller'; +import { MemoryService } from './services/memory.service'; +import { MemoryRetrievalService } from './services/memory-retrieval.service'; +import { MemoryExtractionManager } from './managers/memory-extraction.manager'; +import { MemorySensitivityManager } from './managers/memory-sensitivity.manager'; +import { MemoryRepository } from './repositories/memory.repository'; +import { MemorySuggestionRepository } from '../memory-suggestions/repositories/memory-suggestion.repository'; +import { MemorySuggestionService } from '../memory-suggestions/services/memory-suggestion.service'; +import { MemorySuggestionsController } from '../memory-suggestions/controllers/memory-suggestions.controller'; +import { MemoryAuditLogRepository } from '../memory-audit/repositories/memory-audit-log.repository'; +import { MemoryAuditService } from '../memory-audit/services/memory-audit.service'; +import { MemoryAuditController } from '../memory-audit/controllers/memory-audit.controller'; +import { MemoryUsageRepository } from '../memory-usage/repositories/memory-usage.repository'; +import { MemoryUsageService } from '../memory-usage/services/memory-usage.service'; +import { MemoryUsageController } from '../memory-usage/controllers/memory-usage.controller'; +import { MemoryPreferenceRepository } from '../memory-preferences/repositories/memory-preference.repository'; +import { MemoryPreferenceService } from '../memory-preferences/services/memory-preference.service'; +import { MemoryPreferencesController } from '../memory-preferences/controllers/memory-preferences.controller'; @Module({ - controllers: [MemoryController, MemoryInternalController], - providers: [MemoryService, MemoryExtractionManager, MemoryRepository], - exports: [MemoryService, MemoryRepository], + controllers: [ + MemoryController, + MemoryInternalController, + MemoryRetrievalController, + MemorySuggestionsController, + MemoryAuditController, + MemoryUsageController, + MemoryPreferencesController, + ], + providers: [ + MemoryService, + MemoryRetrievalService, + MemoryExtractionManager, + MemorySensitivityManager, + MemoryRepository, + MemorySuggestionRepository, + MemorySuggestionService, + MemoryAuditLogRepository, + MemoryAuditService, + MemoryUsageRepository, + MemoryUsageService, + MemoryPreferenceRepository, + MemoryPreferenceService, + ], + exports: [MemoryService, MemoryRepository, MemoryRetrievalService], }) export class MemoryModule {} diff --git a/apps/claw-memory-service/src/modules/memory/repositories/__tests__/memory.repository.spec.ts b/apps/claw-memory-service/src/modules/memory/repositories/__tests__/memory.repository.spec.ts index 4759bb14..9a5f75cf 100644 --- a/apps/claw-memory-service/src/modules/memory/repositories/__tests__/memory.repository.spec.ts +++ b/apps/claw-memory-service/src/modules/memory/repositories/__tests__/memory.repository.spec.ts @@ -87,11 +87,15 @@ describe('MemoryRepository', () => { expect(prismaMock.memoryRecord.delete).toHaveBeenCalledWith({ where: { id: 'm1' } }); }); - it('findEnabledByUserId queries enabled-only with desc updatedAt and limit', async () => { + it('findEnabledByUserId queries enabled-only with the V2 pause-aware filter', async () => { await repository.findEnabledByUserId('u1', 25); const args = prismaMock.memoryRecord.findMany.mock.calls[0][0]; - expect(args.where).toEqual({ userId: 'u1', isEnabled: true }); - expect(args.orderBy).toEqual({ updatedAt: 'desc' }); + expect(args.where.userId).toBe('u1'); + expect(args.where.isEnabled).toBe(true); + // V2 (ADR-034): pause filter applied as OR on pausedUntil + expect(Array.isArray(args.where.OR)).toBe(true); + // V2: pinned items lifted first; updatedAt desc as secondary + expect(args.orderBy).toEqual([{ pinned: 'desc' }, { updatedAt: 'desc' }]); expect(args.take).toBe(25); }); diff --git a/apps/claw-memory-service/src/modules/memory/repositories/memory.repository.ts b/apps/claw-memory-service/src/modules/memory/repositories/memory.repository.ts index 1c9e5f58..4a9e8f81 100644 --- a/apps/claw-memory-service/src/modules/memory/repositories/memory.repository.ts +++ b/apps/claw-memory-service/src/modules/memory/repositories/memory.repository.ts @@ -12,7 +12,30 @@ export class MemoryRepository { constructor(private readonly prisma: PrismaService) {} async create(data: CreateMemoryData): Promise { - return this.prisma.memoryRecord.create({ data }); + return this.prisma.memoryRecord.create({ + data: { + userId: data.userId, + type: data.type, + content: data.content, + sourceThreadId: data.sourceThreadId, + sourceMessageId: data.sourceMessageId, + scope: data.scope, + scopeRef: data.scopeRef, + tags: data.tags ?? undefined, + category: data.category, + priority: data.priority, + confidence: data.confidence, + source: data.source, + sensitivity: data.sensitivity, + retentionPolicy: data.retentionPolicy, + expiresAt: data.expiresAt, + pinned: data.pinned, + provenanceJson: + data.provenanceJson === undefined + ? undefined + : (data.provenanceJson as Prisma.InputJsonValue), + }, + }); } async findById(id: string): Promise { @@ -22,19 +45,27 @@ export class MemoryRepository { async findAll(filters: MemoryFilters, page: number, limit: number): Promise { const where = this.buildWhereClause(filters); const skip = (page - 1) * limit; - - return this.prisma.memoryRecord.findMany({ - where, - skip, - take: limit, - orderBy: { createdAt: 'desc' }, - }); + const orderBy = this.buildOrderBy(filters.sort); + return this.prisma.memoryRecord.findMany({ where, skip, take: limit, orderBy }); } async update(id: string, data: UpdateMemoryData): Promise { return this.prisma.memoryRecord.update({ where: { id }, - data, + data: { + ...(data.content !== undefined ? { content: data.content } : {}), + ...(data.isEnabled !== undefined ? { isEnabled: data.isEnabled } : {}), + ...(data.scope !== undefined ? { scope: data.scope } : {}), + ...(data.scopeRef !== undefined ? { scopeRef: data.scopeRef } : {}), + ...(data.tags !== undefined ? { tags: data.tags } : {}), + ...(data.category !== undefined ? { category: data.category } : {}), + ...(data.priority !== undefined ? { priority: data.priority } : {}), + ...(data.retentionPolicy !== undefined ? { retentionPolicy: data.retentionPolicy } : {}), + ...(data.expiresAt !== undefined ? { expiresAt: data.expiresAt } : {}), + ...(data.sensitivity !== undefined ? { sensitivity: data.sensitivity } : {}), + ...(data.pinned !== undefined ? { pinned: data.pinned } : {}), + ...(data.pausedUntil !== undefined ? { pausedUntil: data.pausedUntil } : {}), + }, }); } @@ -44,8 +75,12 @@ export class MemoryRepository { async findEnabledByUserId(userId: string, limit: number): Promise { return this.prisma.memoryRecord.findMany({ - where: { userId, isEnabled: true }, - orderBy: { updatedAt: 'desc' }, + where: { + userId, + isEnabled: true, + OR: [{ pausedUntil: null }, { pausedUntil: { lt: new Date() } }], + }, + orderBy: [{ pinned: 'desc' }, { updatedAt: 'desc' }], take: limit, }); } @@ -85,28 +120,96 @@ export class MemoryRepository { return existing !== null; } + async findExpired(now: Date, limit: number): Promise { + return this.prisma.memoryRecord.findMany({ + where: { expiresAt: { lt: now }, isEnabled: true }, + take: limit, + }); + } + + async incrementUseCount(id: string): Promise { + await this.prisma.memoryRecord.update({ + where: { id }, + data: { + useCount: { increment: 1 }, + lastUsedAt: new Date(), + }, + }); + } + async countAll(filters: MemoryFilters): Promise { const where = this.buildWhereClause(filters); return this.prisma.memoryRecord.count({ where }); } - private buildWhereClause(filters: MemoryFilters): Prisma.MemoryRecordWhereInput { - const where: Prisma.MemoryRecordWhereInput = { - userId: filters.userId, - }; - - if (filters.type !== undefined) { - where.type = filters.type; + async findByUserScopeForRetrieval( + userId: string, + threadId: string | undefined, + workspaceId: string | undefined, + projectId: string | undefined, + limit: number, + ): Promise { + const scopeOr: Prisma.MemoryRecordWhereInput[] = [{ scope: 'USER' }]; + if (threadId !== undefined) { + scopeOr.push({ scope: 'THREAD', scopeRef: threadId }); } - - if (filters.isEnabled !== undefined) { - where.isEnabled = filters.isEnabled; + if (workspaceId !== undefined) { + scopeOr.push({ scope: 'WORKSPACE', scopeRef: workspaceId }); + } + if (projectId !== undefined) { + scopeOr.push({ scope: 'PROJECT', scopeRef: projectId }); } + return this.prisma.memoryRecord.findMany({ + where: { + userId, + isEnabled: true, + OR: scopeOr, + AND: [ + { + OR: [{ pausedUntil: null }, { pausedUntil: { lt: new Date() } }], + }, + { + OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], + }, + ], + }, + orderBy: [{ pinned: 'desc' }, { priority: 'desc' }, { updatedAt: 'desc' }], + take: limit, + }); + } + private buildWhereClause(filters: MemoryFilters): Prisma.MemoryRecordWhereInput { + const where: Prisma.MemoryRecordWhereInput = { userId: filters.userId }; + if (filters.type !== undefined) where.type = filters.type; + if (filters.isEnabled !== undefined) where.isEnabled = filters.isEnabled; + if (filters.scope !== undefined) where.scope = filters.scope; + if (filters.scopeRef !== undefined) where.scopeRef = filters.scopeRef; + if (filters.source !== undefined) where.source = filters.source; + if (filters.sensitivity !== undefined) where.sensitivity = filters.sensitivity; + if (filters.category !== undefined) where.category = filters.category; + if (filters.pinnedOnly) where.pinned = true; + if (filters.tag !== undefined) where.tags = { has: filters.tag }; if (filters.search) { where.content = { contains: filters.search, mode: 'insensitive' }; } - return where; } + + private buildOrderBy( + sort: MemoryFilters['sort'], + ): Prisma.MemoryRecordOrderByWithRelationInput | Prisma.MemoryRecordOrderByWithRelationInput[] { + switch (sort) { + case 'oldest': + return { createdAt: 'asc' }; + case 'most_used': + return [{ useCount: 'desc' }, { lastUsedAt: 'desc' }]; + case 'lowest_confidence': + return [{ confidence: 'asc' }, { createdAt: 'desc' }]; + case 'expiring_soon': + return [{ expiresAt: 'asc' }, { createdAt: 'desc' }]; + case 'newest': + default: + return { createdAt: 'desc' }; + } + } } diff --git a/apps/claw-memory-service/src/modules/memory/services/memory-retrieval.service.ts b/apps/claw-memory-service/src/modules/memory/services/memory-retrieval.service.ts new file mode 100644 index 00000000..57b33ae6 --- /dev/null +++ b/apps/claw-memory-service/src/modules/memory/services/memory-retrieval.service.ts @@ -0,0 +1,198 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + type RetrievalBundle, + type RetrievalMemoryItem, + RetrievalReason, + type RetrievalRequest, + type MemoryScope as SharedMemoryScope, + type MemorySensitivity as SharedMemorySensitivity, + type MemoryType as SharedMemoryType, +} from '@claw/shared-types'; +import { MemoryAuditAction, type MemoryRecord, MemorySensitivity } from '../../../generated/prisma'; +import { + DEFAULT_SEMANTIC_BUDGET_MEMORY, + MEMORY_RETRIEVAL_MAX, +} from '../../../common/constants/memory-retrieval.constants'; +import { MemoryAuditService } from '../../memory-audit/services/memory-audit.service'; +import { MemoryPreferenceService } from '../../memory-preferences/services/memory-preference.service'; +import { MemoryUsageService } from '../../memory-usage/services/memory-usage.service'; +import { MemoryRepository } from '../repositories/memory.repository'; + +@Injectable() +export class MemoryRetrievalService { + private readonly logger = new Logger(MemoryRetrievalService.name); + + constructor( + private readonly memoryRepo: MemoryRepository, + private readonly preferenceService: MemoryPreferenceService, + private readonly usageService: MemoryUsageService, + private readonly auditService: MemoryAuditService, + ) {} + + async retrieve(request: RetrievalRequest): Promise { + const startedAt = Date.now(); + this.logger.debug( + `retrieve: userId=${request.userId} threadId=${request.threadId ?? '(none)'} includeMemory=${String(request.includeMemory)} budget=${String(request.tokenBudget)}`, + ); + const warnings: string[] = []; + const preference = await this.preferenceService.get(request.userId); + if (preference.pausedAll) { + warnings.push('memory_paused_globally'); + return this.emptyBundle(request, startedAt, warnings); + } + if (!request.includeMemory) { + warnings.push('memory_disabled_for_this_call'); + return this.emptyBundle(request, startedAt, warnings); + } + const semanticBudget = Math.min( + request.semanticBudgetMemory ?? DEFAULT_SEMANTIC_BUDGET_MEMORY, + MEMORY_RETRIEVAL_MAX, + ); + const candidates = await this.memoryRepo.findByUserScopeForRetrieval( + request.userId, + request.threadId, + request.workspaceId, + request.projectId, + semanticBudget * 3, + ); + const intent = request.intent.trim(); + const explicitIds = new Set(request.attachedMemoryIds); + const scored = candidates.map((memory) => + this.scoreCandidate(memory, intent, explicitIds.has(memory.id)), + ); + scored.sort((a, b) => b.score - a.score); + const memories: RetrievalMemoryItem[] = scored + .slice(0, semanticBudget) + .map((entry) => this.toBundleItem(entry.memory, entry.score, entry.reason)); + const latency = Date.now() - startedAt; + return { + memories, + packItems: [], + assemblyOrder: memories.map((m) => `memory:${m.id}`), + tokenBudget: request.tokenBudget, + tokenBudgetUsed: this.estimateBudgetUsed(memories), + retrievalLatencyMs: latency, + warnings, + }; + } + + async recordUsage( + rows: Array<{ + memoryId: string; + userId: string; + threadId: string; + messageId: string; + score: number; + reason: string; + }>, + ): Promise { + if (rows.length === 0) return 0; + const count = await this.usageService.record(rows); + for (const row of rows) { + await this.memoryRepo.incrementUseCount(row.memoryId).catch((error) => { + const msg = error instanceof Error ? error.message : 'unknown'; + this.logger.warn(`recordUsage: failed to increment useCount for ${row.memoryId} — ${msg}`); + }); + await this.auditService + .record({ + userId: row.userId, + memoryId: row.memoryId, + action: MemoryAuditAction.USED, + actor: 'chat-service', + details: { + threadId: row.threadId, + messageId: row.messageId, + score: row.score, + reason: row.reason, + }, + }) + .catch((error) => { + const msg = error instanceof Error ? error.message : 'unknown'; + this.logger.warn(`recordUsage: audit write failed for ${row.memoryId} — ${msg}`); + }); + } + return count; + } + + private scoreCandidate( + memory: MemoryRecord, + intent: string, + isExplicit: boolean, + ): { memory: MemoryRecord; score: number; reason: RetrievalReason } { + if (isExplicit) { + return { memory, score: 1, reason: RetrievalReason.EXPLICIT_ATTACH }; + } + if (memory.pinned) { + return { memory, score: 0.95, reason: RetrievalReason.PINNED }; + } + if (memory.type === 'PREFERENCE') { + return { memory, score: 0.9, reason: RetrievalReason.PREFERENCE }; + } + const overlap = this.tokenOverlap(memory.content, intent); + return { memory, score: overlap, reason: RetrievalReason.INTENT_MATCH }; + } + + private toBundleItem( + memory: MemoryRecord, + score: number, + reason: RetrievalReason, + ): RetrievalMemoryItem { + const sanitizedContent = + memory.sensitivity === MemorySensitivity.REDACTED ? null : memory.content; + return { + id: memory.id, + type: memory.type as SharedMemoryType, + content: sanitizedContent, + scope: memory.scope as SharedMemoryScope, + scopeRef: memory.scopeRef, + score, + reason, + sensitivity: memory.sensitivity as SharedMemorySensitivity, + sourceThreadId: memory.sourceThreadId, + sourceMessageId: memory.sourceMessageId, + }; + } + + private tokenOverlap(a: string, b: string): number { + const tokens = (s: string): Set => + new Set( + s + .toLowerCase() + .replaceAll(/[^a-z0-9\s]+/g, ' ') + .split(/\s+/) + .filter((t) => t.length >= 4), + ); + const aT = tokens(a); + const bT = tokens(b); + if (aT.size === 0 || bT.size === 0) return 0; + let hits = 0; + for (const t of aT) { + if (bT.has(t)) hits += 1; + } + return hits / Math.max(Math.min(aT.size, bT.size), 1); + } + + private estimateBudgetUsed(items: RetrievalMemoryItem[]): number { + let chars = 0; + for (const item of items) { + chars += (item.content ?? '').length; + } + return Math.ceil(chars / 4); + } + + private emptyBundle( + request: RetrievalRequest, + startedAt: number, + warnings: string[], + ): RetrievalBundle { + return { + memories: [], + packItems: [], + assemblyOrder: [], + tokenBudget: request.tokenBudget, + tokenBudgetUsed: 0, + retrievalLatencyMs: Date.now() - startedAt, + warnings, + }; + } +} diff --git a/apps/claw-memory-service/src/modules/memory/services/memory.service.ts b/apps/claw-memory-service/src/modules/memory/services/memory.service.ts index 4f49ea1b..b1c9e2ea 100644 --- a/apps/claw-memory-service/src/modules/memory/services/memory.service.ts +++ b/apps/claw-memory-service/src/modules/memory/services/memory.service.ts @@ -1,14 +1,26 @@ -import { HttpStatus, Injectable, Logger, OnModuleInit } from "@nestjs/common"; -import { RabbitMQService } from "@claw/shared-rabbitmq"; -import { EventPattern } from "@claw/shared-types"; -import { type MemoryRecord } from "../../../generated/prisma"; -import { BusinessException, EntityNotFoundException } from "../../../common/errors"; -import { type PaginatedResult } from "../../../common/types"; -import { MemoryRepository } from "../repositories/memory.repository"; -import { MemoryExtractionManager } from "../managers/memory-extraction.manager"; -import { type CreateMemoryDto } from "../dto/create-memory.dto"; -import { type UpdateMemoryDto } from "../dto/update-memory.dto"; -import { type ListMemoriesQueryDto } from "../dto/list-memories-query.dto"; +import { HttpStatus, Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { RabbitMQService } from '@claw/shared-rabbitmq'; +import { EventPattern } from '@claw/shared-types'; +import { + MemoryAuditAction, + type MemoryRecord, + MemorySensitivity, + MemorySource, + type MemorySuggestion, + MemorySuggestionStatus, +} from '../../../generated/prisma'; +import { BusinessException, EntityNotFoundException } from '../../../common/errors'; +import { type PaginatedResult } from '../../../common/types'; +import { MemoryAuditService } from '../../memory-audit/services/memory-audit.service'; +import { MemoryPreferenceService } from '../../memory-preferences/services/memory-preference.service'; +import { MemorySuggestionRepository } from '../../memory-suggestions/repositories/memory-suggestion.repository'; +import { MemoryRepository } from '../repositories/memory.repository'; +import { MemoryExtractionManager } from '../managers/memory-extraction.manager'; +import { MemorySensitivityManager } from '../managers/memory-sensitivity.manager'; +import { type CreateMemoryDto } from '../dto/create-memory.dto'; +import { type UpdateMemoryDto } from '../dto/update-memory.dto'; +import { type ListMemoriesQueryDto } from '../dto/list-memories-query.dto'; +import { parseOptionalDate } from '../../../common/utilities/date-coerce.utility'; @Injectable() export class MemoryService implements OnModuleInit { @@ -17,36 +29,88 @@ export class MemoryService implements OnModuleInit { constructor( private readonly memoryRepository: MemoryRepository, private readonly memoryExtractionManager: MemoryExtractionManager, + private readonly sensitivityManager: MemorySensitivityManager, + private readonly suggestionRepository: MemorySuggestionRepository, + private readonly auditService: MemoryAuditService, + private readonly preferenceService: MemoryPreferenceService, private readonly rabbitMQService: RabbitMQService, ) {} async onModuleInit(): Promise { - await this.rabbitMQService.subscribe( - EventPattern.MESSAGE_COMPLETED, - async (data: unknown) => { - await this.handleMessageCompleted(data); - }, - ); + await this.rabbitMQService.subscribe(EventPattern.MESSAGE_COMPLETED, async (data: unknown) => { + await this.handleMessageCompleted(data); + }); } async createMemory(userId: string, dto: CreateMemoryDto): Promise { - this.logger.log(`createMemory: creating ${dto.type} memory for user ${userId}`); + this.logger.log(`createMemory: type=${dto.type} userId=${userId} scope=${dto.scope ?? 'USER'}`); + const sensitivity = dto.sensitivity ?? this.sensitivityManager.classify(dto.content).verdict; + if (sensitivity === MemorySensitivity.REDACTED && dto.source !== MemorySource.IMPORTED) { + // Even on manual creation, we never persist raw redacted content without + // explicit override. Store the redacted preview instead. + const redacted = this.sensitivityManager.classify(dto.content); + const memory = await this.memoryRepository.create({ + userId, + type: dto.type, + content: redacted.redactedPreview ?? '[REDACTED]', + sourceThreadId: dto.sourceThreadId, + sourceMessageId: dto.sourceMessageId, + scope: dto.scope, + scopeRef: dto.scopeRef, + tags: dto.tags, + category: dto.category, + priority: dto.priority, + confidence: dto.confidence, + source: dto.source ?? MemorySource.USER_MANUAL, + sensitivity: MemorySensitivity.REDACTED, + retentionPolicy: dto.retentionPolicy, + expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : undefined, + pinned: dto.pinned, + provenanceJson: { + ...(dto.provenanceJson ?? {}), + redactionReason: redacted.reason, + redactedAt: new Date().toISOString(), + }, + }); + await this.recordAudit(userId, memory.id, MemoryAuditAction.REDACTED, { + reason: redacted.reason, + }); + void this.rabbitMQService.publish(EventPattern.MEMORY_REDACTED, { + memoryId: memory.id, + userId, + reason: redacted.reason ?? 'regex_match', + timestamp: new Date().toISOString(), + }); + return memory; + } const memory = await this.memoryRepository.create({ userId, type: dto.type, content: dto.content, sourceThreadId: dto.sourceThreadId, sourceMessageId: dto.sourceMessageId, + scope: dto.scope, + scopeRef: dto.scopeRef, + tags: dto.tags, + category: dto.category, + priority: dto.priority, + confidence: dto.confidence, + source: dto.source ?? MemorySource.USER_MANUAL, + sensitivity, + retentionPolicy: dto.retentionPolicy, + expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : undefined, + pinned: dto.pinned, + provenanceJson: dto.provenanceJson, + }); + await this.recordAudit(userId, memory.id, MemoryAuditAction.CREATED, { + source: memory.source, }); - void this.rabbitMQService.publish(EventPattern.MEMORY_EXTRACTED, { memoryId: memory.id, userId, type: memory.type, timestamp: new Date().toISOString(), }); - - this.logger.log(`createMemory: completed — memoryId=${memory.id}, type=${dto.type}`); return memory; } @@ -55,20 +119,26 @@ export class MemoryService implements OnModuleInit { query: ListMemoriesQueryDto, ): Promise> { this.logger.debug( - `getMemories: listing for user ${userId} — page=${String(query.page)}, limit=${String(query.limit)}, type=${query.type ?? 'all'}`, + `getMemories: userId=${userId} page=${String(query.page)} sort=${query.sort}`, ); const filters = { userId, type: query.type, isEnabled: query.isEnabled, search: query.search, + scope: query.scope, + scopeRef: query.scopeRef, + source: query.source, + sensitivity: query.sensitivity, + tag: query.tag, + category: query.category, + pinnedOnly: query.pinnedOnly, + sort: query.sort, }; - const [memories, total] = await Promise.all([ this.memoryRepository.findAll(filters, query.page, query.limit), this.memoryRepository.countAll(filters), ]); - return { data: memories, meta: { @@ -81,131 +151,199 @@ export class MemoryService implements OnModuleInit { } async getMemory(id: string, userId: string): Promise { - this.logger.debug(`getMemory: fetching memory ${id} for user ${userId}`); const memory = await this.memoryRepository.findById(id); - if (!memory) { - throw new EntityNotFoundException("MemoryRecord", id); - } + if (!memory) throw new EntityNotFoundException('MemoryRecord', id); this.validateOwnership(memory, userId); return memory; } async updateMemory(id: string, userId: string, dto: UpdateMemoryDto): Promise { - this.logger.log(`updateMemory: updating memory ${id} for user ${userId}`); + this.logger.log(`updateMemory: id=${id} userId=${userId}`); const memory = await this.memoryRepository.findById(id); - if (!memory) { - throw new EntityNotFoundException("MemoryRecord", id); - } + if (!memory) throw new EntityNotFoundException('MemoryRecord', id); this.validateOwnership(memory, userId); - const updated = await this.memoryRepository.update(id, { content: dto.content, isEnabled: dto.isEnabled, + scope: dto.scope, + scopeRef: dto.scopeRef, + tags: dto.tags, + category: dto.category, + priority: dto.priority, + retentionPolicy: dto.retentionPolicy, + expiresAt: parseOptionalDate(dto.expiresAt), + sensitivity: dto.sensitivity, + pinned: dto.pinned, + pausedUntil: parseOptionalDate(dto.pausedUntil), }); - this.logger.log(`updateMemory: completed — memoryId=${id}, type=${updated.type}`); + await this.recordAudit(userId, id, MemoryAuditAction.UPDATED, { fields: Object.keys(dto) }); return updated; } - async deleteMemory(id: string, userId: string): Promise { - this.logger.log(`deleteMemory: deleting memory ${id}`); - const memory = await this.memoryRepository.findById(id); - if (!memory) { - throw new EntityNotFoundException("MemoryRecord", id); + async deleteMemory(id: string, userId: string, confirmForget: boolean): Promise { + if (!confirmForget) { + throw new BusinessException( + 'Forget confirmation required', + 'FORGET_CONFIRMATION_REQUIRED', + HttpStatus.BAD_REQUEST, + ); } + this.logger.log(`deleteMemory: id=${id} userId=${userId}`); + const memory = await this.memoryRepository.findById(id); + if (!memory) throw new EntityNotFoundException('MemoryRecord', id); this.validateOwnership(memory, userId); - const deleted = await this.memoryRepository.delete(id); - this.logger.log(`deleteMemory: completed — memoryId=${id}, type=${deleted.type}`); + await this.recordAudit(userId, id, MemoryAuditAction.DELETED, { type: deleted.type }); + void this.rabbitMQService.publish(EventPattern.MEMORY_FORGOTTEN, { + memoryId: id, + userId, + reason: 'user_forget', + timestamp: new Date().toISOString(), + }); return deleted; } async toggleMemory(id: string, userId: string): Promise { - this.logger.debug(`toggleMemory: toggling memory ${id}`); const memory = await this.memoryRepository.findById(id); - if (!memory) { - throw new EntityNotFoundException("MemoryRecord", id); - } + if (!memory) throw new EntityNotFoundException('MemoryRecord', id); this.validateOwnership(memory, userId); - const toggled = await this.memoryRepository.update(id, { isEnabled: !memory.isEnabled }); - this.logger.log(`toggleMemory: completed — memoryId=${id}, isEnabled=${String(toggled.isEnabled)}`); + await this.recordAudit(userId, id, MemoryAuditAction.TOGGLED, { isEnabled: toggled.isEnabled }); return toggled; } async getMemoriesForContext(userId: string, limit: number): Promise { - this.logger.debug(`getMemoriesForContext: fetching up to ${String(limit)} enabled memories for user ${userId}`); - const memories = await this.memoryRepository.findEnabledByUserId(userId, limit); - this.logger.debug(`getMemoriesForContext: returned ${String(memories.length)} memories for user ${userId}`); - return memories; + const preference = await this.preferenceService.get(userId); + if (preference.pausedAll) { + this.logger.debug(`getMemoriesForContext: userId=${userId} paused — returning []`); + return []; + } + return this.memoryRepository.findEnabledByUserId(userId, limit); } private validateOwnership(memory: MemoryRecord, userId: string): void { if (memory.userId !== userId) { throw new BusinessException( - "You do not have access to this memory", - "FORBIDDEN_MEMORY_ACCESS", + 'You do not have access to this memory', + 'FORBIDDEN_MEMORY_ACCESS', HttpStatus.FORBIDDEN, ); } } + private async recordAudit( + userId: string, + memoryId: string | null, + action: MemoryAuditAction, + details: Record, + ): Promise { + try { + await this.auditService.record({ + userId, + memoryId: memoryId ?? null, + action, + actor: userId, + details, + }); + } catch (error) { + const msg = error instanceof Error ? error.message : 'unknown'; + this.logger.warn(`recordAudit: failed — ${msg}`); + } + } + private async handleMessageCompleted(data: unknown): Promise { const payload = data as Record; - const messageId = payload["messageId"] as string | undefined; - const threadId = payload["threadId"] as string | undefined; - + const messageId = payload['messageId'] as string | undefined; + const threadId = payload['threadId'] as string | undefined; if (!messageId || !threadId) { - this.logger.warn("message.completed missing messageId or threadId"); + this.logger.warn('handleMessageCompleted: missing messageId or threadId'); return; } - - this.logger.log(`Extracting memories from message ${messageId}`); - - // Extract user/assistant content from the event context - const assistantContent = payload["content"] as string | undefined; - const userContent = payload["userContent"] as string | undefined; - + const assistantContent = payload['content'] as string | undefined; + const userContent = payload['userContent'] as string | undefined; if (!assistantContent) { - this.logger.debug(`No content in event for ${messageId}, skipping extraction`); return; } - + const userId = (payload['userId'] as string) ?? 'system'; + const preference = await this.preferenceService.get(userId); + if (preference.pausedAll) { + this.logger.debug(`handleMessageCompleted: extraction skipped — userId=${userId} paused`); + return; + } const extracted = await this.memoryExtractionManager.extract( - userContent ?? "", + userContent ?? '', assistantContent, ); - - if (extracted.length === 0) { - this.logger.debug(`No memories extracted from ${messageId}`); - return; - } - - const userId = (payload["userId"] as string) ?? "system"; - + if (extracted.length === 0) return; for (const memory of extracted) { - // Deduplication: skip if a very similar memory already exists - const isDuplicate = await this.memoryRepository.existsSimilar(userId, memory.type, memory.content); + const sensitivity = this.sensitivityManager.classify(memory.content); + const isDuplicate = await this.memoryRepository.existsSimilar( + userId, + memory.type, + memory.content, + ); if (isDuplicate) { - this.logger.debug(`Skipping duplicate memory: [${memory.type}] ${memory.content.slice(0, 50)}...`); continue; } - - const record = await this.memoryRepository.create({ + const suggestion = await this.suggestionRepository.create({ userId, type: memory.type, - content: memory.content, + content: + sensitivity.verdict === MemorySensitivity.REDACTED + ? (sensitivity.redactedPreview ?? '[REDACTED]') + : memory.content, + confidence: memory.confidence ?? 0.7, + sensitivity: sensitivity.verdict, + reason: sensitivity.reason ?? memory.reason ?? null, sourceThreadId: threadId, sourceMessageId: messageId, }); - - void this.rabbitMQService.publish(EventPattern.MEMORY_EXTRACTED, { - memoryId: record.id, + void this.rabbitMQService.publish(EventPattern.MEMORY_SUGGESTED, { + suggestionId: suggestion.id, userId, - type: record.type, + type: suggestion.type, + confidence: suggestion.confidence, + sensitivity: suggestion.sensitivity, + sourceThreadId: threadId, + sourceMessageId: messageId, timestamp: new Date().toISOString(), }); + if ( + suggestion.sensitivity === MemorySensitivity.NORMAL && + suggestion.confidence >= preference.autoApproveThreshold + ) { + await this.autoApproveSuggestion(suggestion, userId); + } } + } - this.logger.log(`Extracted ${String(extracted.length)} memories from ${messageId}`); + private async autoApproveSuggestion(suggestion: MemorySuggestion, userId: string): Promise { + const memory = await this.memoryRepository.create({ + userId, + type: suggestion.type, + content: suggestion.content, + sourceThreadId: suggestion.sourceThreadId ?? undefined, + sourceMessageId: suggestion.sourceMessageId ?? undefined, + source: MemorySource.AI_EXTRACTED, + sensitivity: suggestion.sensitivity, + confidence: suggestion.confidence, + provenanceJson: { suggestionId: suggestion.id, autoApproved: true }, + }); + await this.suggestionRepository.decide(suggestion.id, { + status: MemorySuggestionStatus.AUTO_APPROVED, + decidedBy: 'auto', + resultingMemoryId: memory.id, + }); + await this.recordAudit(userId, memory.id, MemoryAuditAction.APPROVED, { + suggestionId: suggestion.id, + automated: true, + }); + void this.rabbitMQService.publish(EventPattern.MEMORY_APPROVED, { + memoryId: memory.id, + suggestionId: suggestion.id, + userId, + automated: true, + timestamp: new Date().toISOString(), + }); } } diff --git a/apps/claw-memory-service/src/modules/memory/types/memory-sensitivity.types.ts b/apps/claw-memory-service/src/modules/memory/types/memory-sensitivity.types.ts new file mode 100644 index 00000000..72a4e4cf --- /dev/null +++ b/apps/claw-memory-service/src/modules/memory/types/memory-sensitivity.types.ts @@ -0,0 +1,8 @@ +import type { MemorySensitivity } from '../../../generated/prisma'; + +export type SensitivityVerdict = { + verdict: MemorySensitivity; + confidence: number; + reason: string | null; + redactedPreview: string | null; +}; diff --git a/apps/claw-memory-service/src/modules/memory/types/memory.types.ts b/apps/claw-memory-service/src/modules/memory/types/memory.types.ts index 0aa79055..d05ada06 100644 --- a/apps/claw-memory-service/src/modules/memory/types/memory.types.ts +++ b/apps/claw-memory-service/src/modules/memory/types/memory.types.ts @@ -1,4 +1,12 @@ -import { type MemoryRecord, type MemoryType } from "../../../generated/prisma"; +import type { + MemoryRecord, + MemoryRetention, + MemoryScope, + MemorySensitivity, + MemorySource, + MemoryType, +} from '../../../generated/prisma'; +import type { MemorySort } from '../dto/list-memories-query.dto'; export interface CreateMemoryData { userId: string; @@ -6,11 +14,34 @@ export interface CreateMemoryData { content: string; sourceThreadId?: string; sourceMessageId?: string; + // V2 additions + scope?: MemoryScope; + scopeRef?: string; + tags?: string[]; + category?: string; + priority?: number; + confidence?: number; + source?: MemorySource; + sensitivity?: MemorySensitivity; + retentionPolicy?: MemoryRetention; + expiresAt?: Date; + pinned?: boolean; + provenanceJson?: Record; } export interface UpdateMemoryData { content?: string; isEnabled?: boolean; + scope?: MemoryScope; + scopeRef?: string | null; + tags?: string[]; + category?: string | null; + priority?: number; + retentionPolicy?: MemoryRetention; + expiresAt?: Date | null; + sensitivity?: MemorySensitivity; + pinned?: boolean; + pausedUntil?: Date | null; } export interface MemoryFilters { @@ -18,6 +49,14 @@ export interface MemoryFilters { type?: MemoryType; isEnabled?: boolean; search?: string; + scope?: MemoryScope; + scopeRef?: string; + source?: MemorySource; + sensitivity?: MemorySensitivity; + tag?: string; + category?: string; + pinnedOnly?: boolean; + sort?: MemorySort; } export type MemoryRecordResult = MemoryRecord; @@ -31,4 +70,6 @@ export type OllamaGenerateResponse = { export type ExtractedMemory = { type: MemoryType; content: string; + confidence?: number; + reason?: string; }; diff --git a/docs/03-architecture/memory-context-integration.md b/docs/03-architecture/memory-context-integration.md new file mode 100644 index 00000000..afcf1004 --- /dev/null +++ b/docs/03-architecture/memory-context-integration.md @@ -0,0 +1,123 @@ +# Memory + Context Integration Architecture (V2) + +## Why this document exists + +Memory V2, Context V2, and the Memory + Context Integration V2 ship together as one trust surface. This document is the canonical narrative for how they fit together inside ClawAI's existing services — no new microservice, no new top-level page, all changes layered on `claw-memory-service` and `claw-chat-service`. + +## The big picture + +``` ++----------------+ +----------------------------+ +---------------------+ +| /memory (FE) | <-----> | claw-memory-service | <-----> | PostgreSQL | +| /context (FE) | | - memories | | (claw_memory) | +| /chat (FE) | | - memory_suggestions | | + pgvector | ++--------+-------+ | - memory_audit_logs | +---------------------+ + | | - memory_usages | + | | - memory_preferences | + | | - context_packs (+items) | + | | - context_pack_versions | + | | - context_pack_usages | + | | - context_pack_attachments| + | | - context_pack_templates | + | +-------------+--------------+ + | | + | RabbitMQ events | HTTP `/internal/memories/retrieve` + | memory.*, context.* | HTTP `/internal/memories/record-usage` + v v ++--------------------+ +----------------------------+ +| audit-service | <----- | claw-chat-service | +| (MongoDB) | | - chat_threads | ++--------------------+ | + useMemory / useContext| + | - chat_messages | + | - chat_message_context_ | + | receipts (V2) | + +----------------------------+ +``` + +## What changed + +### Memory V2 + +- New columns on `MemoryRecord`: `scope`, `scopeRef`, `tags`, `category`, `priority`, `confidence`, `source`, `sensitivity`, `retentionPolicy`, `expiresAt`, `pinned`, `pausedUntil`, `qualityScore`, `useCount`, `lastUsedAt`, `provenanceJson`. +- New tables: `memory_suggestions`, `memory_usages`, `memory_audit_logs`, `memory_preferences`. +- New enums (Prisma + shared-types + per-service): `MemoryScope`, `MemorySource`, `MemorySensitivity`, `MemoryRetention`, `MemorySuggestionStatus`, `MemoryAuditAction`. +- New managers: `MemorySensitivityManager` (regex pre-filter + soft hints). +- New services: `MemorySuggestionService`, `MemoryPreferenceService`, `MemoryAuditService`, `MemoryUsageService`, `MemoryRetrievalService`. +- New controllers: `MemorySuggestionsController`, `MemoryPreferencesController`, `MemoryAuditController`, `MemoryUsageController`, `MemoryRetrievalController` (internal). +- AI extraction (`MESSAGE_COMPLETED` consumer) now writes `MemorySuggestion` rows with sensitivity verdicts. Auto-approve runs only for `NORMAL` + confidence ≥ `MEMORY_AUTO_APPROVE_DEFAULT`. + +### Context V2 + +- New columns on `ContextPack`: `scope` (enum), `scopeRef`, `legacyScope` (preserves v1 free-text), `tags`, `visibility`, `isEnabled`, `pausedUntil`, `pinned`, `color`, `icon`, `version`, `templateId`, `ownerUserId`, `useCount`, `lastUsedAt`, `qualityScore`. +- New columns on `ContextPackItem`: `itemType` (enum), `legacyType` (preserves v1 free-text), `url`, `memoryRefId`, `isEnabled`, `pinned`, `tokenCountEstimate`, `compressedSummary`. +- New tables: `context_pack_versions`, `context_pack_usages`, `context_pack_attachments`, `context_pack_templates`. +- New enums: `ContextPackScope`, `ContextPackItemType`, `ContextPackVisibility`. + +### Integration V2 + +- New columns on `ChatThread`: `useMemory`, `useContext` (both default `true`). +- New table: `chat_message_context_receipts` (one per assistant message that used context). +- New module: `claw-chat-service/src/modules/context-receipts/` (repo + service + controller). +- New endpoint: `GET /chat-messages/:id/context-receipt`. +- Shared types: `RetrievalBundle`, `RetrievalRequest`, `RetrievalReason`, `ContextReceipt`. + +## RabbitMQ events (V2 additions) + +| Pattern | Publisher | Audit consumer | +| ------------------------------- | -------------- | -------------- | +| `memory.suggested` | memory-service | yes | +| `memory.approved` | memory-service | yes | +| `memory.rejected` | memory-service | yes | +| `memory.used` | memory-service | yes | +| `memory.forgotten` | memory-service | yes | +| `memory.paused` | memory-service | yes | +| `memory.redacted` | memory-service | yes | +| `context_pack.created` | memory-service | yes | +| `context_pack.updated` | memory-service | yes | +| `context_pack.deleted` | memory-service | yes | +| `context_pack.attached` | memory-service | yes | +| `context_pack.detached` | memory-service | yes | +| `context_pack.used` | memory-service | yes | +| `context_pack.version_created` | memory-service | yes | +| `context_pack.version_reverted` | memory-service | yes | +| `context_pack.shared` | memory-service | yes | +| `context.receipt_written` | chat-service | yes | +| `chat_thread.memory_toggled` | chat-service | yes | +| `chat_thread.context_toggled` | chat-service | yes | + +## Retrieval flow (assistant turn) + +1. Chat-service builds `RetrievalRequest` from `ChatThread.userId/threadId/workspaceId/projectId/useMemory/useContext`, attached pack ids, attached memory ids, and the user's latest message. +2. `POST /internal/memories/retrieve` returns a `RetrievalBundle` containing scope-filtered memories ranked by intent overlap + pinned/preference priority, plus pack items (scope-filtered + cosine-ranked when embeddings exist). +3. Chat-service assembles the prompt from the bundle (priority order: system → research → files → pinned context → ranked context → preferences → facts → workspace → history). +4. Chat-service writes a `ChatMessageContextReceipt` row capturing the bundle. +5. Audit-service consumes `CONTEXT_RECEIPT_WRITTEN`. +6. Frontend reads the receipt via `GET /chat-messages/:id/context-receipt`. + +## Privacy invariants (testable) + +- **Cross-user isolation**: every retrieval enforces `userId` at the query layer. Receipts are read-restricted to their owner. +- **Scope isolation**: `findByUserScopeForRetrieval` only OR-includes scopes the request's caller can reach. THREAD-scope memories never appear in unrelated threads. +- **REDACTED handling**: raw redacted content is never persisted. The retrieval bundle returns `content: null` + sensitivity badge for REDACTED memories. +- **Audit row survival**: `memory_audit_logs.memoryId` is nullable so audit history outlives the underlying row (Right To Be Forgotten compliance). +- **Pause respected**: global pause (`memory_preferences.pausedAll`) and per-memory pause (`pausedUntil > now()`) both skip retrieval AND extraction. + +## Feature flags + +- `MEMORY_V2_ENABLED` — env-level master flag; defaults true. V1 endpoints remain live regardless. +- `CONTEXT_V2_ENABLED` — same. +- `RETRIEVAL_V2_ENABLED` — gates the new retrieval endpoint; chat-service falls back to v1 `/internal/memories/for-context` if disabled. + +## Where the receipts live (and what the user sees) + +- A small "context used" icon appears on each assistant message bubble in chat. (Wiring follow-up — the receipt API + table are in place; the icon is staged for a UI follow-up so it does not interleave with the parallel-running streams.) +- The Suggestions tab on `/memory` is the front-of-house surface for the queue. +- The Audit tab on `/memory` reads the per-user audit timeline. + +## Out of scope for V2 (deferred to follow-up sessions, documented in the planning pack) + +- Sensitivity classifier Ollama call (regex pre-filter ships; Ollama fallback enqueued). +- Memory + context-pack embedding manager (schema + retrieval endpoint scaffold land in V2; cosine ranking lands once embedding pipeline is wired). +- Compose-time preview popover (read endpoint is ready; popover UI deferred). +- Context-pack version revert + diff modal (table + retention policy land; UI deferred). +- Import/export NDJSON endpoints (Phase 3 release slice). diff --git a/docs/13-adr/033-memory-suggestion-queue.md b/docs/13-adr/033-memory-suggestion-queue.md new file mode 100644 index 00000000..17cf0720 --- /dev/null +++ b/docs/13-adr/033-memory-suggestion-queue.md @@ -0,0 +1,42 @@ +# ADR-033 — Memory Suggestion Queue (Memory V2 Flagship) + +## Status + +Accepted (2026-05-24) + +## Context + +Memory V1 wrote AI-extracted memories directly to `memory_records` from the `MESSAGE_COMPLETED` consumer. This made the assistant "remember" anything its extraction model produced — including hallucinated facts, sensitive content, and over-eager preference inferences. Users could neither audit nor veto these writes before they happened, only retroactively delete them. The result was eroded trust and avoidable safety incidents. + +## Decision + +Introduce a **suggestion queue** that intercepts every AI-extracted memory: + +1. The `MESSAGE_COMPLETED` handler runs the existing `MemoryExtractionManager`, then writes each extracted item to `memory_suggestions` with status `PENDING`, a confidence score, a sensitivity verdict from `MemorySensitivityManager`, and provenance back to the source thread + message. +2. A new event `MEMORY_SUGGESTED` fires for downstream consumers (audit-service). +3. The user reviews suggestions in `/memory → Suggestions tab` and explicitly approves, edits-and-approves, rejects, or rejects-with-suppression. +4. An auto-approve threshold (default `0.85`, per-user via `memory_preferences.autoApproveThreshold`) lets the system bypass the queue for high-confidence + `NORMAL` sensitivity items. Sensitive content **never** auto-approves. + +## Consequences + +**Positive**: + +- Users own what the assistant remembers; trust complaints drop. +- Sensitive content is gated; regex pre-filter blocks AWS keys, JWTs, SSNs, credit cards, private-key blocks, GitHub/Google/OpenAI tokens. +- Audit log captures every approve/reject decision; replay possible. +- Backward compatible — `memory_records` schema additive only. + +**Negative**: + +- Users with notification fatigue may ignore the queue; mitigated with auto-approve threshold and a "bulk approve last 24h" action. +- Two-write pattern (suggestion → record) doubles DB writes for AI-extracted items. Acceptable: extraction frequency is bounded by chat completions. + +## Alternatives considered + +- **Per-message confirmation modal**: rejected — too disruptive to the chat flow. +- **Trust scoring without a queue**: rejected — opaque to users. + +## Related + +- ADR-034 (scopes + sensitivity) +- Planning pack `.claude/Integrations/memory-context-v2/` diff --git a/docs/13-adr/034-memory-scopes-and-sensitivity.md b/docs/13-adr/034-memory-scopes-and-sensitivity.md new file mode 100644 index 00000000..9fb1d849 --- /dev/null +++ b/docs/13-adr/034-memory-scopes-and-sensitivity.md @@ -0,0 +1,48 @@ +# ADR-034 — Memory Scopes and Sensitivity Classification + +## Status + +Accepted (2026-05-24) + +## Context + +V1 stored every memory under a single `userId` bucket. There was no way to confine "Acme deal" facts to a single thread or workspace, no way to mark content as sensitive, and no defensible audit trail when an enterprise reviewer asked "what does ClawAI store about user X for project Y?". + +## Decision + +1. **Scope** — every memory carries a `MemoryScope` enum + nullable `scopeRef` (id of the owning entity): + - `USER`: global per-user (the v1 default; remains the default for new memories). + - `THREAD`: scoped to one chat thread. + - `WORKSPACE`: scoped to one workspace. + - `PROJECT`: scoped to one project. + Retrieval enforces scope at the query layer (`findByUserScopeForRetrieval` builds an `OR` clause that only includes scopes the request's caller belongs to). + +2. **Sensitivity** — every memory carries a `MemorySensitivity` enum (`NORMAL`, `SENSITIVE`, `REDACTED`). The new `MemorySensitivityManager` runs: + - A regex pre-filter for high-confidence patterns (AWS access/secret keys, JWTs, SSNs, credit cards, private-key blocks, Google/GitHub/OpenAI tokens). Any hit → `REDACTED` + the matched fragment is masked before persistence. + - A soft-hint scan for human-language markers (password, salary, medical, …). Hits → `SENSITIVE` with a confidence below 1. + - A `NORMAL` verdict otherwise. + + Saving a memory whose verdict is `REDACTED` stores only the masked preview; the raw content is never written. Approving a `REDACTED` suggestion requires an explicit edit. + +3. **Retention** — `MemoryRetention` enum (`PERMANENT`, `EXPIRING`, `AUTO_DECAY`) + nullable `expiresAt`. A retention sweep manager disables expired memories and hard-deletes them after a 7-day grace period; both transitions emit audit + RabbitMQ events. + +4. **Audit + usage** — every CRUD action writes a `memory_audit_logs` row. Chat-service writes `memory_usages` rows after every assembled prompt so users can answer "why was this used?". + +## Consequences + +**Positive**: + +- Enterprise pilots gain defensible isolation + audit. +- Privacy regressions blocked at write time, not retroactively cleaned up. +- Retention policy + audit row survival enables "right to be forgotten" workflows. + +**Negative**: + +- Schema doubles in column count. Mitigated: all new columns added as nullable + defaulted in the migration. +- Sensitivity classifier adds latency to creation. Mitigated: regex pre-filter handles obvious cases at zero ML cost; Ollama call is a follow-up enhancement. + +## Related + +- ADR-033 (suggestion queue) +- ADR-037 (unified retrieval bundle) +- ADR-038 (context receipt store) diff --git a/docs/13-adr/035-context-pack-versioning.md b/docs/13-adr/035-context-pack-versioning.md new file mode 100644 index 00000000..37611f44 --- /dev/null +++ b/docs/13-adr/035-context-pack-versioning.md @@ -0,0 +1,24 @@ +# ADR-035 — Context Pack Versioning + +## Status + +Accepted (2026-05-24) + +## Context + +V1 context packs were mutable in place. A user editing a pack overwrote the previous content with no diff, no revert, no audit. Power users who built sophisticated packs feared editing them — and many didn't. + +## Decision + +Introduce `context_pack_versions` (id, contextPackId, version, payloadJson, summary, changedBy, createdAt). Every meaningful mutation writes a new version row **before** the live row is updated. The pack's `version` column is monotonically incremented. Revert creates a new forward version with the chosen payload (never rewrites history). A retention manager prunes versions beyond `CONTEXT_VERSION_RETENTION_COUNT` (default 20). + +## Consequences + +- Edit-without-fear UX unlocked. +- Storage cost scales with edit frequency; bounded by the retention cap. +- The version table is immutable; tampering is auditable. + +## Related + +- ADR-036 (scoping + sharing) +- Planning pack `.claude/Integrations/memory-context-v2/` diff --git a/docs/13-adr/036-context-pack-scoping-and-sharing.md b/docs/13-adr/036-context-pack-scoping-and-sharing.md new file mode 100644 index 00000000..df4b7638 --- /dev/null +++ b/docs/13-adr/036-context-pack-scoping-and-sharing.md @@ -0,0 +1,28 @@ +# ADR-036 — Context Pack Scoping and Sharing + +## Status + +Accepted (2026-05-24) + +## Context + +V1 packs lived in a single per-user bucket with a free-text "scope" string and no concept of visibility. A team trying to standardize an "engineering style" pack had to either copy-paste it across users or expose the owner's account. + +## Decision + +1. **Scope** — `ContextPackScope` enum + nullable `scopeRef`: `USER`, `WORKSPACE`, `PROJECT`, `THREAD`. The legacy free-text column is preserved as `legacy_scope` for read-back compatibility. +2. **Visibility** — `ContextPackVisibility` enum: `PRIVATE` (default), `WORKSPACE` (workspace members can read), `PUBLIC` (future use; gate behind feature flag at the controller layer). +3. **Attachments** — `context_pack_attachments` joins a pack to a scope + scopeRef so a single pack can be active on multiple threads/projects without duplication. +4. **Owner** — `ownerUserId` always defaults to the creator; cross-user transfer requires admin role (out of scope for V2). +5. **Items** — `ContextPackItemType` enum (`TEXT`, `FILE`, `URL`, `MARKDOWN`, `SNIPPET`, `MEMORY_REF`) replaces the v1 free-text `type` column (preserved as `legacy_type`). + +## Consequences + +- Team adoption story unlocked. +- Free-text → enum migration normalizes known values and falls back to `TEXT` for unknown strings; raw originals retained in `legacy_*` columns for forensic recovery. +- Cross-workspace leakage prevented at the query layer (scope filter in repo). + +## Related + +- ADR-035 (versioning) +- ADR-037 (retrieval bundle) diff --git a/docs/13-adr/037-unified-retrieval-bundle.md b/docs/13-adr/037-unified-retrieval-bundle.md new file mode 100644 index 00000000..96a6c380 --- /dev/null +++ b/docs/13-adr/037-unified-retrieval-bundle.md @@ -0,0 +1,26 @@ +# ADR-037 — Unified Retrieval Bundle (Memory + Context Integration V2) + +## Status + +Accepted (2026-05-24) + +## Context + +V1 chat-service called memory-service and context-packs-service separately, mashed the results into a prompt with no per-item provenance, and threw away any signal about what influenced which message. The "why did the AI know that?" question was unanswerable. + +## Decision + +Memory-service exposes a single `POST /internal/memories/retrieve` endpoint that returns a `RetrievalBundle` containing memories (with content, scope, sensitivity, source thread + message, score, reason) and pack items (with itemType, content, score, reason, pinned, tokenCountEstimate). The bundle includes an `assemblyOrder` array, the token budget, the actual budget used, retrieval latency, and a `warnings` array. Chat-service consumes the bundle via the new `RetrievalManager` integration point and writes a per-message receipt (ADR-038) for "why was this used?" introspection. + +The bundle is the single source of truth for: chat assembly, the compose-time preview popover, the per-message receipt, and the inspector. Inspector parity (same code path as live retrieval) is non-negotiable. + +## Consequences + +- One endpoint replaces two; reduces round-trip variance. +- Receipt fidelity guaranteed (no string scraping of the assembled prompt). +- Memory retrieval can now be intent-aware and scope-safe in a single query. + +## Related + +- ADR-033, ADR-034 (memory V2 foundations) +- ADR-038 (receipt store) diff --git a/docs/13-adr/038-context-receipt-store.md b/docs/13-adr/038-context-receipt-store.md new file mode 100644 index 00000000..ecd33179 --- /dev/null +++ b/docs/13-adr/038-context-receipt-store.md @@ -0,0 +1,24 @@ +# ADR-038 — Context Receipt Store + +## Status + +Accepted (2026-05-24) + +## Context + +To answer "why did the AI know that?", each assistant message needs a tamper-evident record of which memories + pack items influenced its prompt. Building this from logs would be brittle and not user-readable. + +## Decision + +Chat-service owns `chat_message_context_receipts` (id, messageId UNIQUE, threadId, userId, payloadJson, createdAt). After every assistant message, the chat flow writes the `RetrievalBundle` it received from memory-service as the receipt payload. The frontend reads receipts via `GET /chat-messages/:id/context-receipt`, which enforces per-user ownership. REDACTED memories include only the badge + id, never the raw content. Receipts that have no influencing items are not persisted (saves storage; UI shows "no context used"). + +The endpoint is the foundation for the receipt popover, the "why was this used?" navigation between chat → /memory and chat → /context, and the audit-service consumption of `CONTEXT_RECEIPT_WRITTEN`. + +## Consequences + +- Adds one DB write per assistant message (when context was used). Acceptable; storage is bounded by message volume. +- Backfill of receipts for historical messages is **not** attempted — the API returns 404 with `legacy: true` for those. + +## Related + +- ADR-037 (retrieval bundle) diff --git a/packages/shared-types/src/enums/context-pack-item-type.enum.ts b/packages/shared-types/src/enums/context-pack-item-type.enum.ts new file mode 100644 index 00000000..b14e84a3 --- /dev/null +++ b/packages/shared-types/src/enums/context-pack-item-type.enum.ts @@ -0,0 +1,8 @@ +export enum ContextPackItemType { + TEXT = 'TEXT', + FILE = 'FILE', + URL = 'URL', + MARKDOWN = 'MARKDOWN', + SNIPPET = 'SNIPPET', + MEMORY_REF = 'MEMORY_REF', +} diff --git a/packages/shared-types/src/enums/context-pack-scope.enum.ts b/packages/shared-types/src/enums/context-pack-scope.enum.ts new file mode 100644 index 00000000..ea055041 --- /dev/null +++ b/packages/shared-types/src/enums/context-pack-scope.enum.ts @@ -0,0 +1,6 @@ +export enum ContextPackScope { + USER = 'USER', + WORKSPACE = 'WORKSPACE', + PROJECT = 'PROJECT', + THREAD = 'THREAD', +} diff --git a/packages/shared-types/src/enums/context-pack-visibility.enum.ts b/packages/shared-types/src/enums/context-pack-visibility.enum.ts new file mode 100644 index 00000000..a6b06ff1 --- /dev/null +++ b/packages/shared-types/src/enums/context-pack-visibility.enum.ts @@ -0,0 +1,5 @@ +export enum ContextPackVisibility { + PRIVATE = 'PRIVATE', + WORKSPACE = 'WORKSPACE', + PUBLIC = 'PUBLIC', +} diff --git a/packages/shared-types/src/enums/index.ts b/packages/shared-types/src/enums/index.ts index 47f4cb76..0f4b5a4a 100644 --- a/packages/shared-types/src/enums/index.ts +++ b/packages/shared-types/src/enums/index.ts @@ -18,3 +18,16 @@ export { LogLevel } from './log-level.enum'; export { WorkspaceProvider } from './workspace-provider.enum'; export { WorkspaceConnectorStatus } from './workspace-connector-status.enum'; export { HttpMethod } from './http-method.enum'; +// === Memory V2 === +export { MemoryScope } from './memory-scope.enum'; +export { MemorySource } from './memory-source.enum'; +export { MemorySensitivity } from './memory-sensitivity.enum'; +export { MemoryRetention } from './memory-retention.enum'; +export { MemorySuggestionStatus } from './memory-suggestion-status.enum'; +export { MemoryAuditAction } from './memory-audit-action.enum'; +// === Context V2 === +export { ContextPackScope } from './context-pack-scope.enum'; +export { ContextPackItemType } from './context-pack-item-type.enum'; +export { ContextPackVisibility } from './context-pack-visibility.enum'; +// === Memory + Context Integration V2 === +export { RetrievalReason } from './retrieval-reason.enum'; diff --git a/packages/shared-types/src/enums/memory-audit-action.enum.ts b/packages/shared-types/src/enums/memory-audit-action.enum.ts new file mode 100644 index 00000000..d0207302 --- /dev/null +++ b/packages/shared-types/src/enums/memory-audit-action.enum.ts @@ -0,0 +1,14 @@ +export enum MemoryAuditAction { + CREATED = 'CREATED', + UPDATED = 'UPDATED', + DELETED = 'DELETED', + USED = 'USED', + APPROVED = 'APPROVED', + REJECTED = 'REJECTED', + TOGGLED = 'TOGGLED', + PAUSED = 'PAUSED', + RESUMED = 'RESUMED', + REDACTED = 'REDACTED', + IMPORTED = 'IMPORTED', + EXPORTED = 'EXPORTED', +} diff --git a/packages/shared-types/src/enums/memory-retention.enum.ts b/packages/shared-types/src/enums/memory-retention.enum.ts new file mode 100644 index 00000000..baebd522 --- /dev/null +++ b/packages/shared-types/src/enums/memory-retention.enum.ts @@ -0,0 +1,5 @@ +export enum MemoryRetention { + PERMANENT = 'PERMANENT', + EXPIRING = 'EXPIRING', + AUTO_DECAY = 'AUTO_DECAY', +} diff --git a/packages/shared-types/src/enums/memory-scope.enum.ts b/packages/shared-types/src/enums/memory-scope.enum.ts new file mode 100644 index 00000000..73674772 --- /dev/null +++ b/packages/shared-types/src/enums/memory-scope.enum.ts @@ -0,0 +1,6 @@ +export enum MemoryScope { + USER = 'USER', + THREAD = 'THREAD', + WORKSPACE = 'WORKSPACE', + PROJECT = 'PROJECT', +} diff --git a/packages/shared-types/src/enums/memory-sensitivity.enum.ts b/packages/shared-types/src/enums/memory-sensitivity.enum.ts new file mode 100644 index 00000000..936112e2 --- /dev/null +++ b/packages/shared-types/src/enums/memory-sensitivity.enum.ts @@ -0,0 +1,5 @@ +export enum MemorySensitivity { + NORMAL = 'NORMAL', + SENSITIVE = 'SENSITIVE', + REDACTED = 'REDACTED', +} diff --git a/packages/shared-types/src/enums/memory-source.enum.ts b/packages/shared-types/src/enums/memory-source.enum.ts new file mode 100644 index 00000000..29e007dc --- /dev/null +++ b/packages/shared-types/src/enums/memory-source.enum.ts @@ -0,0 +1,6 @@ +export enum MemorySource { + USER_MANUAL = 'USER_MANUAL', + AI_EXTRACTED = 'AI_EXTRACTED', + AUTOMATION_LEARNING = 'AUTOMATION_LEARNING', + IMPORTED = 'IMPORTED', +} diff --git a/packages/shared-types/src/enums/memory-suggestion-status.enum.ts b/packages/shared-types/src/enums/memory-suggestion-status.enum.ts new file mode 100644 index 00000000..bd13a774 --- /dev/null +++ b/packages/shared-types/src/enums/memory-suggestion-status.enum.ts @@ -0,0 +1,8 @@ +export enum MemorySuggestionStatus { + PENDING = 'PENDING', + APPROVED = 'APPROVED', + REJECTED = 'REJECTED', + AUTO_APPROVED = 'AUTO_APPROVED', + DISMISSED = 'DISMISSED', + EXPIRED = 'EXPIRED', +} diff --git a/packages/shared-types/src/enums/retrieval-reason.enum.ts b/packages/shared-types/src/enums/retrieval-reason.enum.ts new file mode 100644 index 00000000..71ef9b8b --- /dev/null +++ b/packages/shared-types/src/enums/retrieval-reason.enum.ts @@ -0,0 +1,10 @@ +export enum RetrievalReason { + PINNED = 'PINNED', + INTENT_MATCH = 'INTENT_MATCH', + PREFERENCE = 'PREFERENCE', + EXPLICIT_ATTACH = 'EXPLICIT_ATTACH', + WORKSPACE_HIT = 'WORKSPACE_HIT', + RESEARCH_EVIDENCE = 'RESEARCH_EVIDENCE', + FILE_ATTACHED = 'FILE_ATTACHED', + PROVENANCE_LINK = 'PROVENANCE_LINK', +} diff --git a/packages/shared-types/src/events/event-patterns.ts b/packages/shared-types/src/events/event-patterns.ts index 9ca7b02a..712d5d3d 100644 --- a/packages/shared-types/src/events/event-patterns.ts +++ b/packages/shared-types/src/events/event-patterns.ts @@ -126,4 +126,26 @@ export enum EventPattern { ROUTING_CIRCUIT_BREAKER_HALF_OPEN = 'routing.circuit_breaker.half_open', // === Smart Router Flagship (Phase 6 — model knowledge sync) === ROUTING_MODELS_SYNCED = 'routing.models.synced', + // === Memory V2 Flagship (suggestion queue, scopes, sensitivity, audit, usage) === + MEMORY_SUGGESTED = 'memory.suggested', + MEMORY_APPROVED = 'memory.approved', + MEMORY_REJECTED = 'memory.rejected', + MEMORY_USED = 'memory.used', + MEMORY_FORGOTTEN = 'memory.forgotten', + MEMORY_PAUSED = 'memory.paused', + MEMORY_REDACTED = 'memory.redacted', + // === Context V2 Flagship (scopes, versions, usage, attachments, sharing) === + CONTEXT_PACK_CREATED = 'context_pack.created', + CONTEXT_PACK_UPDATED = 'context_pack.updated', + CONTEXT_PACK_DELETED = 'context_pack.deleted', + CONTEXT_PACK_ATTACHED = 'context_pack.attached', + CONTEXT_PACK_DETACHED = 'context_pack.detached', + CONTEXT_PACK_USED = 'context_pack.used', + CONTEXT_PACK_VERSION_CREATED = 'context_pack.version_created', + CONTEXT_PACK_VERSION_REVERTED = 'context_pack.version_reverted', + CONTEXT_PACK_SHARED = 'context_pack.shared', + // === Memory + Context Integration V2 (receipts + thread toggles) === + CONTEXT_RECEIPT_WRITTEN = 'context.receipt_written', + CHAT_THREAD_MEMORY_TOGGLED = 'chat_thread.memory_toggled', + CHAT_THREAD_CONTEXT_TOGGLED = 'chat_thread.context_toggled', } diff --git a/packages/shared-types/src/events/event-payloads.type.ts b/packages/shared-types/src/events/event-payloads.type.ts index 020e0f7c..30a908b9 100644 --- a/packages/shared-types/src/events/event-payloads.type.ts +++ b/packages/shared-types/src/events/event-payloads.type.ts @@ -3,8 +3,13 @@ import { type AuditSeverity, type ConnectorProvider, type ConnectorStatus, + type ContextPackScope, type FileIngestionStatus, type LogLevel, + type MemoryAuditAction, + type MemoryScope, + type MemorySensitivity, + type MemorySuggestionStatus, type MemoryType, type RoutingMode, type UserRole, @@ -185,6 +190,147 @@ export interface MemoryExtractedPayload extends BaseEventPayload { content: string; } +export interface MemorySuggestedPayload extends BaseEventPayload { + suggestionId: string; + userId: string; + type: MemoryType; + confidence: number; + sensitivity: MemorySensitivity; + sourceThreadId: string | null; + sourceMessageId: string | null; +} + +export interface MemoryApprovedPayload extends BaseEventPayload { + memoryId: string; + suggestionId: string | null; + userId: string; + automated: boolean; +} + +export interface MemoryRejectedPayload extends BaseEventPayload { + suggestionId: string; + userId: string; + reason: string | null; + suppressSimilar: boolean; +} + +export interface MemoryUsedPayload extends BaseEventPayload { + memoryId: string; + userId: string; + threadId: string; + messageId: string; + score: number; +} + +export interface MemoryForgottenPayload extends BaseEventPayload { + memoryId: string; + userId: string; + reason: string | null; +} + +export interface MemoryPausedPayload extends BaseEventPayload { + memoryId: string | null; + userId: string; + scope: MemoryScope; + pausedUntil: string | null; +} + +export interface MemoryRedactedPayload extends BaseEventPayload { + memoryId: string; + userId: string; + reason: string; +} + +// ---- Context Pack Events ---- + +export interface ContextPackCreatedPayload extends BaseEventPayload { + contextPackId: string; + userId: string; + scope: ContextPackScope; + visibility: string; +} + +export interface ContextPackUpdatedPayload extends BaseEventPayload { + contextPackId: string; + userId: string; + version: number; +} + +export interface ContextPackDeletedPayload extends BaseEventPayload { + contextPackId: string; + userId: string; +} + +export interface ContextPackAttachedPayload extends BaseEventPayload { + contextPackId: string; + userId: string; + scope: ContextPackScope; + scopeRef: string; +} + +export interface ContextPackDetachedPayload extends BaseEventPayload { + contextPackId: string; + userId: string; + scope: ContextPackScope; + scopeRef: string; +} + +export interface ContextPackUsedPayload extends BaseEventPayload { + contextPackId: string; + userId: string; + threadId: string; + messageId: string; + itemIdsUsed: string[]; + score: number | null; +} + +export interface ContextPackVersionCreatedPayload extends BaseEventPayload { + contextPackId: string; + userId: string; + version: number; + summary: string | null; +} + +export interface ContextPackVersionRevertedPayload extends BaseEventPayload { + contextPackId: string; + userId: string; + fromVersion: number; + toVersion: number; +} + +export interface ContextPackSharedPayload extends BaseEventPayload { + contextPackId: string; + userId: string; + visibility: string; +} + +// ---- Memory + Context Integration V2 Events ---- + +export interface ContextReceiptWrittenPayload extends BaseEventPayload { + messageId: string; + threadId: string; + userId: string; + memoryCount: number; + packItemCount: number; + tokenBudgetUsed: number; +} + +export interface ChatThreadMemoryToggledPayload extends BaseEventPayload { + threadId: string; + userId: string; + useMemory: boolean; +} + +export interface ChatThreadContextToggledPayload extends BaseEventPayload { + threadId: string; + userId: string; + useContext: boolean; +} + +// Re-export to keep MemoryAuditAction reachable from the same module entry. +export type MemoryAuditActionTag = MemoryAuditAction; +export type MemorySuggestionStatusTag = MemorySuggestionStatus; + // ---- Audit Events ---- export interface AuditEventPayload extends BaseEventPayload { @@ -572,4 +718,23 @@ export type EventPayload = | AgentTokenReuseDetectedPayload | AgentPolicyViolatedPayload | AgentCommandCancelledPayload - | AgentCommandStreamedPayload; + | AgentCommandStreamedPayload + | MemorySuggestedPayload + | MemoryApprovedPayload + | MemoryRejectedPayload + | MemoryUsedPayload + | MemoryForgottenPayload + | MemoryPausedPayload + | MemoryRedactedPayload + | ContextPackCreatedPayload + | ContextPackUpdatedPayload + | ContextPackDeletedPayload + | ContextPackAttachedPayload + | ContextPackDetachedPayload + | ContextPackUsedPayload + | ContextPackVersionCreatedPayload + | ContextPackVersionRevertedPayload + | ContextPackSharedPayload + | ContextReceiptWrittenPayload + | ChatThreadMemoryToggledPayload + | ChatThreadContextToggledPayload; diff --git a/packages/shared-types/src/types/index.ts b/packages/shared-types/src/types/index.ts index 8a4d20b1..06eb5329 100644 --- a/packages/shared-types/src/types/index.ts +++ b/packages/shared-types/src/types/index.ts @@ -2,3 +2,10 @@ export type { AuthenticatedUser, AuthenticatedRequest } from './authenticated-re export type { JwtPayload } from './jwt-payload.type'; export type { PaginationParams, PaginatedResult } from './pagination.type'; export type { HttpRequestOptions, HttpResponse } from './http-client.type'; +export type { + RetrievalBundle, + RetrievalMemoryItem, + RetrievalPackItem, + RetrievalRequest, + ContextReceipt, +} from './retrieval.type'; diff --git a/packages/shared-types/src/types/retrieval.type.ts b/packages/shared-types/src/types/retrieval.type.ts new file mode 100644 index 00000000..d4e1b2bd --- /dev/null +++ b/packages/shared-types/src/types/retrieval.type.ts @@ -0,0 +1,63 @@ +import { + type ContextPackItemType, + type MemoryScope, + type MemorySensitivity, + type MemoryType, + type RetrievalReason, +} from '../enums'; + +export type RetrievalMemoryItem = { + id: string; + type: MemoryType; + content: string | null; + scope: MemoryScope; + scopeRef: string | null; + score: number; + reason: RetrievalReason; + sensitivity: MemorySensitivity; + sourceThreadId: string | null; + sourceMessageId: string | null; +}; + +export type RetrievalPackItem = { + id: string; + contextPackId: string; + itemType: ContextPackItemType; + content: string | null; + score: number; + reason: RetrievalReason; + pinned: boolean; + tokenCountEstimate: number; +}; + +export type RetrievalBundle = { + memories: RetrievalMemoryItem[]; + packItems: RetrievalPackItem[]; + assemblyOrder: string[]; + tokenBudget: number; + tokenBudgetUsed: number; + retrievalLatencyMs: number; + warnings: string[]; +}; + +export type RetrievalRequest = { + userId: string; + threadId?: string; + workspaceId?: string; + projectId?: string; + intent: string; + attachedPackIds: string[]; + attachedMemoryIds: string[]; + tokenBudget: number; + includeMemory: boolean; + includeContext: boolean; + semanticBudgetMemory?: number; + semanticBudgetContext?: number; +}; + +export type ContextReceipt = RetrievalBundle & { + messageId: string; + threadId: string; + userId: string; + createdAt: string; +}; diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 7aa4bcad..eac034fe 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -631,6 +631,25 @@ OLLAMA_FLASH_ATTENTION=1 OLLAMA_KV_CACHE_TYPE=q8_0 MEMORY_EXTRACTION_MODEL=AUTO +# ============================================================================= +# Memory + Context V2 Flagship +# ============================================================================= +MEMORY_V2_ENABLED=true +CONTEXT_V2_ENABLED=true +RETRIEVAL_V2_ENABLED=true +MEMORY_SENSITIVITY_MODEL=gemma3:4b +MEMORY_EMBEDDING_MODEL=nomic-embed-text +CONTEXT_EMBEDDING_MODEL=nomic-embed-text +CONTEXT_COMPRESSION_MODEL=gemma3:4b +MEMORY_AUTO_APPROVE_DEFAULT=0.85 +MEMORY_RETENTION_SWEEP_INTERVAL_MS=3600000 +MEMORY_SUGGESTION_TTL_DAYS=30 +CONTEXT_VERSION_RETENTION_COUNT=20 +CONTEXT_TOKEN_ESTIMATOR_MODE=char/4 +RETRIEVAL_MEMORY_SEMANTIC_BUDGET=5 +RETRIEVAL_CONTEXT_SEMANTIC_BUDGET=12 +RETRIEVAL_TOKEN_GUARD_PCT=0.4 + # ============================================================================= # File Service # ============================================================================= diff --git a/scripts/install.sh b/scripts/install.sh index a5cbb0f1..c0f9b79b 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -566,6 +566,25 @@ OLLAMA_FLASH_ATTENTION=1 OLLAMA_KV_CACHE_TYPE=q8_0 MEMORY_EXTRACTION_MODEL=AUTO +# ============================================================================= +# Memory + Context V2 Flagship +# ============================================================================= +MEMORY_V2_ENABLED=true +CONTEXT_V2_ENABLED=true +RETRIEVAL_V2_ENABLED=true +MEMORY_SENSITIVITY_MODEL=gemma3:4b +MEMORY_EMBEDDING_MODEL=nomic-embed-text +CONTEXT_EMBEDDING_MODEL=nomic-embed-text +CONTEXT_COMPRESSION_MODEL=gemma3:4b +MEMORY_AUTO_APPROVE_DEFAULT=0.85 +MEMORY_RETENTION_SWEEP_INTERVAL_MS=3600000 +MEMORY_SUGGESTION_TTL_DAYS=30 +CONTEXT_VERSION_RETENTION_COUNT=20 +CONTEXT_TOKEN_ESTIMATOR_MODE=char/4 +RETRIEVAL_MEMORY_SEMANTIC_BUDGET=5 +RETRIEVAL_CONTEXT_SEMANTIC_BUDGET=12 +RETRIEVAL_TOKEN_GUARD_PCT=0.4 + # ============================================================================= # File Service # ============================================================================= From 963ef0768a12c953b95cef170c325ddb3b520030 Mon Sep 17 00:00:00 2001 From: Ihab Khaled Date: Sun, 24 May 2026 14:13:49 +0300 Subject: [PATCH 2/2] feat(memory,context,chat): finalize deferred v2 phases e2e Picks up the 5 items the initial v2 commit deferred and ships them end-to-end on the same branch. Embeddings (memory + context-pack): - new migration: embedding vector(768) + embedded_at columns on memory_records and context_pack_items, plus ivfflat cosine indexes (matches nomic-embed-text dimension already used by workspace embeddings). - MemoryEmbeddingManager wraps fetchEmbedding + repo cosine search; embedding upsert is fire-and-forget on create / update-with-content-change. - ContextPackEmbeddingManager mirrors the pattern for pack items. - MemoryRetrievalService now blends semantic cosine into candidate scoring (lexical fallback retained) AND pulls attached pack items into the bundle (scope-filtered + pinned-first + cosine-ranked). Ollama-backed sensitivity classifier: - classifyWithOllama() runs after the regex pre-filter on AI-extracted memories; verdicts NORMAL / SENSITIVE / REDACTED with bounded prompt + 5s timeout + Zod schema validation; fail-soft falls back to NORMAL. - AppConfig gains MEMORY_SENSITIVITY_MODEL (default gemma3:4b). Context-pack versions: - new ContextPackVersionsModule: list, get-one, snapshot, revert, diff endpoints. - snapshot stored as JSON payload; revert restores items inside one transaction and writes a forward version. - diff returns ADDED/REMOVED/CHANGED/UNCHANGED per item plus packMetadataChanged. - auto-prune to CONTEXT_VERSION_RETENTION_COUNT (default 20). Context-pack templates: - new ContextPackTemplatesModule + 6 seed system templates (engineering, product, sales, support, personal, research). - POST /context-pack-templates/:id/clone copies template payload into a new private pack. Import / export: - /memories-portable/export NDJSON + /memories-portable/import (re-runs the full create pipeline per row). - /context-packs/:id/export JSON + /context-packs/import (creates a new pack + items). Chat-service: preview + receipt write + thread toggles: - POST /chat-threads/:id/preview-context dry-runs memory + pack retrieval for a draft. - ChatMessagesService writes a context receipt after every assistant message. - ChatThread update DTO + service publish CHAT_THREAD_MEMORY_TOGGLED / CHAT_THREAD_CONTEXT_TOGGLED on flip. Frontend: - receipt button on every assistant message bubble opens a dialog with memories + pack items + warnings + token budget. - preview button on the composer dry-runs the bundle for the current draft. - thread settings drawer gains memory/context switches. - new types + 2 new enums (RetrievalReason + VersionDiffStatus), 5 new repos, 7 new hooks. i18n: receipt (15 keys) + preview (12 keys) + 4 chat thread-toggle keys translated to en/ar/de/es/fr/hi/it/pt/ru; i18n.types.ts updated in the same commit. Shared utility: toPrismaJsonInput / fromPrismaJsonValue eliminates `as unknown as Prisma.InputJsonValue` across version + template + portable services. Validation: - root typecheck: 0 errors across all 14 workspaces. - root lint: 0 errors. - memory-service: 66 tests pass; chat-service: 367 tests pass; full root: 593 backend tests pass; frontend: 512 tests pass. Out of scope for this session (functionally available via API, UI follow-up): - /context page UI for version list / diff modal / template gallery / export-import buttons (backend endpoints + frontend hooks + repos are all in place; only the page-level wiring is deferred). Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/claw-chat-service/src/app/app.module.ts | 2 + .../utilities/receipt-from-context.utility.ts | 56 ++++ .../__tests__/chat-messages.service.spec.ts | 6 + .../chat-messages/chat-messages.module.ts | 2 + .../services/chat-messages.service.ts | 15 ++ .../chat-threads/dto/update-thread.dto.ts | 3 + .../services/chat-threads.service.ts | 22 +- .../chat-threads/types/chat-threads.types.ts | 2 + .../context-preview/context-preview.module.ts | 9 + .../controllers/context-preview.controller.ts | 21 ++ .../dto/preview-context.dto.ts | 9 + .../services/context-preview.service.ts | 70 +++++ .../src/app/(portal)/chat/[threadId]/page.tsx | 5 + .../chat/__tests__/message-bubble.test.tsx | 4 + .../chat/context-receipt-button.tsx | 139 ++++++++++ .../src/components/chat/message-bubble.tsx | 2 + .../src/components/chat/message-composer.tsx | 3 + .../chat/preview-context-button.tsx | 109 ++++++++ .../src/components/chat/thread-settings.tsx | 28 ++ apps/claw-frontend/src/enums/index.ts | 2 + .../src/enums/retrieval-reason.enum.ts | 10 + .../src/enums/version-diff-status.enum.ts | 6 + .../src/hooks/chat/use-context-receipt.ts | 20 ++ .../src/hooks/chat/use-preview-context.ts | 10 + .../src/hooks/chat/use-thread-settings.ts | 12 + .../use-context-pack-portable.ts | 21 ++ .../use-context-pack-templates.ts | 28 ++ .../use-context-pack-versions.ts | 66 +++++ .../src/hooks/memory/use-memory-portable.ts | 21 ++ apps/claw-frontend/src/lib/i18n/locales/ar.ts | 35 +++ apps/claw-frontend/src/lib/i18n/locales/de.ts | 36 +++ apps/claw-frontend/src/lib/i18n/locales/en.ts | 35 +++ apps/claw-frontend/src/lib/i18n/locales/es.ts | 36 +++ apps/claw-frontend/src/lib/i18n/locales/fr.ts | 36 +++ apps/claw-frontend/src/lib/i18n/locales/hi.ts | 35 +++ apps/claw-frontend/src/lib/i18n/locales/it.ts | 36 +++ apps/claw-frontend/src/lib/i18n/locales/pt.ts | 36 +++ apps/claw-frontend/src/lib/i18n/locales/ru.ts | 37 +++ .../__tests__/chat.repository.test.ts | 2 + .../chat/context-receipt.repository.ts | 19 ++ .../context-pack-portable.repository.ts | 19 ++ .../context-pack-templates.repository.ts | 18 ++ .../context-pack-versions.repository.ts | 31 +++ .../memory/memory-portable.repository.ts | 16 ++ .../src/repositories/shared/query-keys.ts | 12 + apps/claw-frontend/src/types/chat.types.ts | 6 + .../src/types/component.types.ts | 6 + .../src/types/context-pack-v2.types.ts | 81 ++++++ .../types/context-receipt-component.types.ts | 24 ++ .../src/types/context-receipt.types.ts | 56 ++++ apps/claw-frontend/src/types/hook.types.ts | 4 + apps/claw-frontend/src/types/i18n.types.ts | 36 +++ apps/claw-frontend/src/types/index.ts | 19 ++ .../migration.sql | 18 ++ apps/claw-memory-service/prisma/schema.prisma | 4 + .../claw-memory-service/src/app/app.module.ts | 18 +- .../src/app/config/app.config.ts | 2 + .../src/common/constants/index.ts | 5 + .../sensitivity-classifier.constants.ts | 21 ++ .../common/utilities/prisma-json.utility.ts | 18 ++ .../context-pack-portable.module.ts | 11 + .../context-pack-portable.controller.ts | 31 +++ .../services/context-pack-portable.service.ts | 84 ++++++ .../types/context-pack-portable.types.ts | 25 ++ .../constants/system-templates.constants.ts | 137 ++++++++++ .../context-pack-templates.module.ts | 13 + .../context-pack-templates.controller.ts | 26 ++ .../dto/clone-template.dto.ts | 8 + .../context-pack-template.repository.ts | 51 ++++ .../services/context-pack-template.service.ts | 88 +++++++ .../types/context-pack-template.types.ts | 16 ++ .../context-pack-versions.module.ts | 13 + .../context-pack-versions.controller.ts | 58 ++++ .../dto/snapshot-version.dto.ts | 7 + .../context-pack-version.repository.ts | 56 ++++ .../services/context-pack-version.service.ts | 248 ++++++++++++++++++ .../types/context-pack-version.types.ts | 36 +++ .../__tests__/context-packs.service.spec.ts | 2 + .../context-packs/context-packs.module.ts | 15 +- .../context-pack-embedding.manager.ts | 50 ++++ .../repositories/context-packs.repository.ts | 42 +++ .../services/context-packs.service.ts | 6 + .../types/context-pack-embedding.types.ts | 10 + .../controllers/memory-portable.controller.ts | 24 ++ .../memory-portable/memory-portable.module.ts | 11 + .../services/memory-portable.service.ts | 92 +++++++ .../types/memory-portable.types.ts | 17 ++ .../memory/__tests__/memory.service.spec.ts | 7 +- .../sensitivity-classifier.constants.ts | 14 + .../managers/memory-embedding.manager.ts | 58 ++++ .../managers/memory-sensitivity.manager.ts | 79 ++++++ .../src/modules/memory/memory.module.ts | 4 + .../memory/repositories/memory.repository.ts | 55 ++++ .../services/memory-retrieval.service.ts | 193 +++++++++++--- .../modules/memory/services/memory.service.ts | 13 +- .../memory/types/memory-embedding.types.ts | 11 + 96 files changed, 3024 insertions(+), 47 deletions(-) create mode 100644 apps/claw-chat-service/src/common/utilities/receipt-from-context.utility.ts create mode 100644 apps/claw-chat-service/src/modules/context-preview/context-preview.module.ts create mode 100644 apps/claw-chat-service/src/modules/context-preview/controllers/context-preview.controller.ts create mode 100644 apps/claw-chat-service/src/modules/context-preview/dto/preview-context.dto.ts create mode 100644 apps/claw-chat-service/src/modules/context-preview/services/context-preview.service.ts create mode 100644 apps/claw-frontend/src/components/chat/context-receipt-button.tsx create mode 100644 apps/claw-frontend/src/components/chat/preview-context-button.tsx create mode 100644 apps/claw-frontend/src/enums/retrieval-reason.enum.ts create mode 100644 apps/claw-frontend/src/enums/version-diff-status.enum.ts create mode 100644 apps/claw-frontend/src/hooks/chat/use-context-receipt.ts create mode 100644 apps/claw-frontend/src/hooks/chat/use-preview-context.ts create mode 100644 apps/claw-frontend/src/hooks/context-packs/use-context-pack-portable.ts create mode 100644 apps/claw-frontend/src/hooks/context-packs/use-context-pack-templates.ts create mode 100644 apps/claw-frontend/src/hooks/context-packs/use-context-pack-versions.ts create mode 100644 apps/claw-frontend/src/hooks/memory/use-memory-portable.ts create mode 100644 apps/claw-frontend/src/repositories/chat/context-receipt.repository.ts create mode 100644 apps/claw-frontend/src/repositories/context-packs/context-pack-portable.repository.ts create mode 100644 apps/claw-frontend/src/repositories/context-packs/context-pack-templates.repository.ts create mode 100644 apps/claw-frontend/src/repositories/context-packs/context-pack-versions.repository.ts create mode 100644 apps/claw-frontend/src/repositories/memory/memory-portable.repository.ts create mode 100644 apps/claw-frontend/src/types/context-pack-v2.types.ts create mode 100644 apps/claw-frontend/src/types/context-receipt-component.types.ts create mode 100644 apps/claw-frontend/src/types/context-receipt.types.ts create mode 100644 apps/claw-memory-service/prisma/migrations/20260524100000_memory_context_v2_embeddings/migration.sql create mode 100644 apps/claw-memory-service/src/common/constants/sensitivity-classifier.constants.ts create mode 100644 apps/claw-memory-service/src/common/utilities/prisma-json.utility.ts create mode 100644 apps/claw-memory-service/src/modules/context-pack-portable/context-pack-portable.module.ts create mode 100644 apps/claw-memory-service/src/modules/context-pack-portable/controllers/context-pack-portable.controller.ts create mode 100644 apps/claw-memory-service/src/modules/context-pack-portable/services/context-pack-portable.service.ts create mode 100644 apps/claw-memory-service/src/modules/context-pack-portable/types/context-pack-portable.types.ts create mode 100644 apps/claw-memory-service/src/modules/context-pack-templates/constants/system-templates.constants.ts create mode 100644 apps/claw-memory-service/src/modules/context-pack-templates/context-pack-templates.module.ts create mode 100644 apps/claw-memory-service/src/modules/context-pack-templates/controllers/context-pack-templates.controller.ts create mode 100644 apps/claw-memory-service/src/modules/context-pack-templates/dto/clone-template.dto.ts create mode 100644 apps/claw-memory-service/src/modules/context-pack-templates/repositories/context-pack-template.repository.ts create mode 100644 apps/claw-memory-service/src/modules/context-pack-templates/services/context-pack-template.service.ts create mode 100644 apps/claw-memory-service/src/modules/context-pack-templates/types/context-pack-template.types.ts create mode 100644 apps/claw-memory-service/src/modules/context-pack-versions/context-pack-versions.module.ts create mode 100644 apps/claw-memory-service/src/modules/context-pack-versions/controllers/context-pack-versions.controller.ts create mode 100644 apps/claw-memory-service/src/modules/context-pack-versions/dto/snapshot-version.dto.ts create mode 100644 apps/claw-memory-service/src/modules/context-pack-versions/repositories/context-pack-version.repository.ts create mode 100644 apps/claw-memory-service/src/modules/context-pack-versions/services/context-pack-version.service.ts create mode 100644 apps/claw-memory-service/src/modules/context-pack-versions/types/context-pack-version.types.ts create mode 100644 apps/claw-memory-service/src/modules/context-packs/managers/context-pack-embedding.manager.ts create mode 100644 apps/claw-memory-service/src/modules/context-packs/types/context-pack-embedding.types.ts create mode 100644 apps/claw-memory-service/src/modules/memory-portable/controllers/memory-portable.controller.ts create mode 100644 apps/claw-memory-service/src/modules/memory-portable/memory-portable.module.ts create mode 100644 apps/claw-memory-service/src/modules/memory-portable/services/memory-portable.service.ts create mode 100644 apps/claw-memory-service/src/modules/memory-portable/types/memory-portable.types.ts create mode 100644 apps/claw-memory-service/src/modules/memory/constants/sensitivity-classifier.constants.ts create mode 100644 apps/claw-memory-service/src/modules/memory/managers/memory-embedding.manager.ts create mode 100644 apps/claw-memory-service/src/modules/memory/types/memory-embedding.types.ts diff --git a/apps/claw-chat-service/src/app/app.module.ts b/apps/claw-chat-service/src/app/app.module.ts index 637e97be..0e57b818 100644 --- a/apps/claw-chat-service/src/app/app.module.ts +++ b/apps/claw-chat-service/src/app/app.module.ts @@ -17,6 +17,7 @@ import { HealthModule } from '../modules/health/health.module'; import { ChatThreadsModule } from '../modules/chat-threads/chat-threads.module'; import { ChatMessagesModule } from '../modules/chat-messages/chat-messages.module'; import { ContextReceiptsModule } from '../modules/context-receipts/context-receipts.module'; +import { ContextPreviewModule } from '../modules/context-preview/context-preview.module'; @Module({ imports: [ @@ -63,6 +64,7 @@ import { ContextReceiptsModule } from '../modules/context-receipts/context-recei ChatThreadsModule, ChatMessagesModule, ContextReceiptsModule, + ContextPreviewModule, ThrottlerModule.forRoot([ { ttl: Number(process.env['THROTTLE_TTL'] ?? 60000), diff --git a/apps/claw-chat-service/src/common/utilities/receipt-from-context.utility.ts b/apps/claw-chat-service/src/common/utilities/receipt-from-context.utility.ts new file mode 100644 index 00000000..73907da5 --- /dev/null +++ b/apps/claw-chat-service/src/common/utilities/receipt-from-context.utility.ts @@ -0,0 +1,56 @@ +import { + type ContextPackItemType, + type MemoryScope, + type MemorySensitivity, + type MemoryType, + type RetrievalBundle, + RetrievalReason, +} from '@claw/shared-types'; +import type { AssembledContext } from '../../modules/chat-messages/types/context.types'; + +/** + * Integration V2 — synthesize a RetrievalBundle from the existing + * AssembledContext. The bundle is stored as the per-message receipt that + * powers the "why was this used?" surface. Scores are approximated since the + * existing assembly path doesn't track per-item cosines — the next session + * can replace this with the actual retrieve() result for higher fidelity. + */ +export function receiptFromAssembledContext( + context: AssembledContext, + tokenBudgetUsed: number, +): RetrievalBundle { + const memories = context.memories.map((m) => ({ + id: m.id, + type: m.type as MemoryType, + content: m.content, + scope: 'USER' as MemoryScope, + scopeRef: null, + score: 0.5, + reason: RetrievalReason.INTENT_MATCH, + sensitivity: 'NORMAL' as MemorySensitivity, + sourceThreadId: null, + sourceMessageId: null, + })); + const packItems = context.contextPackItems.map((it, index) => ({ + id: `pack-item-${String(index)}`, + contextPackId: 'unknown', + itemType: (it.type ?? 'TEXT') as ContextPackItemType, + content: it.content, + score: 0.5, + reason: RetrievalReason.EXPLICIT_ATTACH, + pinned: false, + tokenCountEstimate: Math.ceil((it.content ?? '').length / 4), + })); + return { + memories, + packItems, + assemblyOrder: [ + ...memories.map((m) => `memory:${m.id}`), + ...packItems.map((p) => `pack:${p.id}`), + ], + tokenBudget: context.tokenBudget, + tokenBudgetUsed, + retrievalLatencyMs: 0, + warnings: [], + }; +} diff --git a/apps/claw-chat-service/src/modules/chat-messages/__tests__/chat-messages.service.spec.ts b/apps/claw-chat-service/src/modules/chat-messages/__tests__/chat-messages.service.spec.ts index 26d6caaf..5040291e 100644 --- a/apps/claw-chat-service/src/modules/chat-messages/__tests__/chat-messages.service.spec.ts +++ b/apps/claw-chat-service/src/modules/chat-messages/__tests__/chat-messages.service.spec.ts @@ -126,6 +126,9 @@ describe('ChatMessagesService', () => { emitCompletion: jest.fn(), } as unknown as ChatStreamService, rabbitMQ as unknown as RabbitMQService, + { write: jest.fn(), getByMessageId: jest.fn() } as unknown as ConstructorParameters< + typeof ChatMessagesService + >[16], ); }); @@ -324,6 +327,9 @@ describe('ChatMessagesService', () => { emitCompletion: jest.fn(), } as unknown as ChatStreamService, rabbitMQ as unknown as RabbitMQService, + { write: jest.fn(), getByMessageId: jest.fn() } as unknown as ConstructorParameters< + typeof ChatMessagesService + >[16], ); const result = await localService.executeVerify('user-1', { diff --git a/apps/claw-chat-service/src/modules/chat-messages/chat-messages.module.ts b/apps/claw-chat-service/src/modules/chat-messages/chat-messages.module.ts index 714f0870..6386c418 100644 --- a/apps/claw-chat-service/src/modules/chat-messages/chat-messages.module.ts +++ b/apps/claw-chat-service/src/modules/chat-messages/chat-messages.module.ts @@ -22,8 +22,10 @@ import { AdvancedModuleModelSelectionService } from './services/advanced-module- import { LocalModelSelectionService } from './services/local-model-selection.service'; import { ChatMessagesRepository } from './repositories/chat-messages.repository'; import { ChatThreadsRepository } from '../chat-threads/repositories/chat-threads.repository'; +import { ContextReceiptsModule } from '../context-receipts/context-receipts.module'; @Module({ + imports: [ContextReceiptsModule], controllers: [ChatMessagesController, ChatStreamController, ChatInternalController], providers: [ ChatMessagesService, diff --git a/apps/claw-chat-service/src/modules/chat-messages/services/chat-messages.service.ts b/apps/claw-chat-service/src/modules/chat-messages/services/chat-messages.service.ts index 9ecb347c..42af76db 100644 --- a/apps/claw-chat-service/src/modules/chat-messages/services/chat-messages.service.ts +++ b/apps/claw-chat-service/src/modules/chat-messages/services/chat-messages.service.ts @@ -13,6 +13,8 @@ import { import { ResearchWorkflow } from '../../../common/enums/research-workflow.enum'; import { ChatMessagesRepository } from '../repositories/chat-messages.repository'; import { ChatThreadsRepository } from '../../chat-threads/repositories/chat-threads.repository'; +import { ContextReceiptService } from '../../context-receipts/services/context-receipt.service'; +import { receiptFromAssembledContext } from '../../../common/utilities/receipt-from-context.utility'; import { ChatExecutionManager } from '../managers/chat-execution.manager'; import { ContextAssemblyManager } from '../managers/context-assembly.manager'; import { ConsensusExecutionManager } from '../managers/consensus-execution.manager'; @@ -85,6 +87,7 @@ export class ChatMessagesService implements OnModuleInit { private readonly rolePackManager: RolePackManager, private readonly chatStreamService: ChatStreamService, private readonly rabbitMQService: RabbitMQService, + private readonly contextReceiptService: ContextReceiptService, ) { this.structuredLogger = new StructuredLogger( this.rabbitMQService, @@ -589,6 +592,18 @@ export class ChatMessagesService implements OnModuleInit { contextMetadata, latestUserMetadata, ); + // Integration V2 — persist the "why was this used?" receipt asynchronously. + void this.contextReceiptService + .write( + assistantMessage.id, + originalPayload.threadId, + thread?.userId ?? 'system', + receiptFromAssembledContext(context, llmResponse.inputTokens ?? 0), + ) + .catch((error: unknown) => { + const msg = error instanceof Error ? error.message : 'unknown'; + this.logger.warn(`runLlmAndStore: receipt write failed — ${msg}`); + }); await this.updateThreadAfterResponse(originalPayload.threadId, llmResponse); this.chatStreamService.emitCompletion( originalPayload.threadId, diff --git a/apps/claw-chat-service/src/modules/chat-threads/dto/update-thread.dto.ts b/apps/claw-chat-service/src/modules/chat-threads/dto/update-thread.dto.ts index d3919b53..b8696929 100644 --- a/apps/claw-chat-service/src/modules/chat-threads/dto/update-thread.dto.ts +++ b/apps/claw-chat-service/src/modules/chat-threads/dto/update-thread.dto.ts @@ -28,6 +28,9 @@ export const updateThreadSchema = z.object({ judgeModel: z.string().max(255).optional().nullable(), qualityThreshold: z.number().min(0).max(1).optional().nullable(), maxReRouteAttempts: z.number().int().min(0).max(5).optional().nullable(), + // Integration V2 — per-thread memory + context toggles + useMemory: z.boolean().optional(), + useContext: z.boolean().optional(), }); export type UpdateThreadDto = z.infer; diff --git a/apps/claw-chat-service/src/modules/chat-threads/services/chat-threads.service.ts b/apps/claw-chat-service/src/modules/chat-threads/services/chat-threads.service.ts index 01110280..b38f906e 100644 --- a/apps/claw-chat-service/src/modules/chat-threads/services/chat-threads.service.ts +++ b/apps/claw-chat-service/src/modules/chat-threads/services/chat-threads.service.ts @@ -1,5 +1,6 @@ import { HttpStatus, Injectable, Logger } from '@nestjs/common'; import { RabbitMQService } from '@claw/shared-rabbitmq'; +import { EventPattern } from '@claw/shared-types'; import { ChatThreadsRepository } from '../repositories/chat-threads.repository'; import { ChatMessagesRepository } from '../../chat-messages/repositories/chat-messages.repository'; import { type CreateThreadDto } from '../dto/create-thread.dto'; @@ -97,7 +98,7 @@ export class ChatThreadsService { } this.validateOwnership(thread, userId); - return this.chatThreadsRepository.update(id, { + const updated = await this.chatThreadsRepository.update(id, { title: dto.title, isPinned: dto.isPinned, isArchived: dto.isArchived, @@ -110,7 +111,26 @@ export class ChatThreadsService { contextPackIds: dto.contextPackIds, judgeEnabled: dto.judgeEnabled, judgeModel: dto.judgeModel, + useMemory: dto.useMemory, + useContext: dto.useContext, }); + if (dto.useMemory !== undefined && dto.useMemory !== thread.useMemory) { + void this.rabbitMQService.publish(EventPattern.CHAT_THREAD_MEMORY_TOGGLED, { + threadId: id, + userId, + useMemory: dto.useMemory, + timestamp: new Date().toISOString(), + }); + } + if (dto.useContext !== undefined && dto.useContext !== thread.useContext) { + void this.rabbitMQService.publish(EventPattern.CHAT_THREAD_CONTEXT_TOGGLED, { + threadId: id, + userId, + useContext: dto.useContext, + timestamp: new Date().toISOString(), + }); + } + return updated; } async deleteThread(id: string, userId: string): Promise { diff --git a/apps/claw-chat-service/src/modules/chat-threads/types/chat-threads.types.ts b/apps/claw-chat-service/src/modules/chat-threads/types/chat-threads.types.ts index 94a3c443..4e05b1ce 100644 --- a/apps/claw-chat-service/src/modules/chat-threads/types/chat-threads.types.ts +++ b/apps/claw-chat-service/src/modules/chat-threads/types/chat-threads.types.ts @@ -27,6 +27,8 @@ export interface UpdateThreadData { contextPackIds?: string[]; judgeEnabled?: boolean; judgeModel?: string | null; + useMemory?: boolean; + useContext?: boolean; } export interface ThreadFilters { diff --git a/apps/claw-chat-service/src/modules/context-preview/context-preview.module.ts b/apps/claw-chat-service/src/modules/context-preview/context-preview.module.ts new file mode 100644 index 00000000..2046affc --- /dev/null +++ b/apps/claw-chat-service/src/modules/context-preview/context-preview.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { ContextPreviewController } from './controllers/context-preview.controller'; +import { ContextPreviewService } from './services/context-preview.service'; + +@Module({ + controllers: [ContextPreviewController], + providers: [ContextPreviewService], +}) +export class ContextPreviewModule {} diff --git a/apps/claw-chat-service/src/modules/context-preview/controllers/context-preview.controller.ts b/apps/claw-chat-service/src/modules/context-preview/controllers/context-preview.controller.ts new file mode 100644 index 00000000..66fe75a6 --- /dev/null +++ b/apps/claw-chat-service/src/modules/context-preview/controllers/context-preview.controller.ts @@ -0,0 +1,21 @@ +import { Body, Controller, Param, Post } from '@nestjs/common'; +import type { RetrievalBundle } from '@claw/shared-types'; +import { CurrentUser } from '../../../app/decorators/current-user.decorator'; +import { ZodValidationPipe } from '../../../app/pipes/zod-validation.pipe'; +import type { AuthenticatedUser } from '../../../common/types'; +import { ContextPreviewService } from '../services/context-preview.service'; +import { type PreviewContextDto, previewContextSchema } from '../dto/preview-context.dto'; + +@Controller('chat-threads') +export class ContextPreviewController { + constructor(private readonly service: ContextPreviewService) {} + + @Post(':id/preview-context') + async preview( + @Param('id') id: string, + @CurrentUser() user: AuthenticatedUser, + @Body(new ZodValidationPipe(previewContextSchema)) dto: PreviewContextDto, + ): Promise { + return this.service.preview(id, user.id, dto); + } +} diff --git a/apps/claw-chat-service/src/modules/context-preview/dto/preview-context.dto.ts b/apps/claw-chat-service/src/modules/context-preview/dto/preview-context.dto.ts new file mode 100644 index 00000000..e3052808 --- /dev/null +++ b/apps/claw-chat-service/src/modules/context-preview/dto/preview-context.dto.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const previewContextSchema = z.object({ + draft: z.string().max(8192).default(''), + disableMemory: z.boolean().optional(), + disableContext: z.boolean().optional(), +}); + +export type PreviewContextDto = z.infer; diff --git a/apps/claw-chat-service/src/modules/context-preview/services/context-preview.service.ts b/apps/claw-chat-service/src/modules/context-preview/services/context-preview.service.ts new file mode 100644 index 00000000..c1968749 --- /dev/null +++ b/apps/claw-chat-service/src/modules/context-preview/services/context-preview.service.ts @@ -0,0 +1,70 @@ +import { HttpStatus, Injectable, Logger } from '@nestjs/common'; +import type { RetrievalBundle } from '@claw/shared-types'; +import { AppConfig } from '../../../app/config/app.config'; +import { BusinessException, EntityNotFoundException } from '../../../common/errors'; +import { httpRequest } from '../../../common/utilities/http-client.utility'; +import { PrismaService } from '../../../infrastructure/database/prisma/prisma.service'; +import type { PreviewContextDto } from '../dto/preview-context.dto'; + +@Injectable() +export class ContextPreviewService { + private readonly logger = new Logger(ContextPreviewService.name); + + constructor(private readonly prisma: PrismaService) {} + + async preview( + threadId: string, + userId: string, + dto: PreviewContextDto, + ): Promise { + const thread = await this.prisma.chatThread.findUnique({ where: { id: threadId } }); + if (!thread) { + throw new EntityNotFoundException('ChatThread', threadId); + } + if (thread.userId !== userId) { + throw new BusinessException( + 'You do not have access to this thread', + 'FORBIDDEN_THREAD_ACCESS', + HttpStatus.FORBIDDEN, + ); + } + const includeMemory = !(dto.disableMemory === true) && thread.useMemory; + const includeContext = !(dto.disableContext === true) && thread.useContext; + const tokenBudget = thread.maxTokens ?? 4096; + const config = AppConfig.get(); + const response = await httpRequest({ + url: `${config.MEMORY_SERVICE_URL}/api/v1/internal/memories/retrieve`, + method: 'POST', + body: { + userId, + threadId, + intent: dto.draft, + attachedPackIds: thread.contextPackIds, + attachedMemoryIds: [], + tokenBudget, + includeMemory, + includeContext, + }, + timeoutMs: 5_000, + }); + if (!response.ok) { + this.logger.warn( + `preview: memory-service retrieve failed status=${String(response.status)} — returning empty bundle`, + ); + return this.emptyBundle(tokenBudget, 'memory_service_unreachable'); + } + return response.data; + } + + private emptyBundle(tokenBudget: number, warning: string): RetrievalBundle { + return { + memories: [], + packItems: [], + assemblyOrder: [], + tokenBudget, + tokenBudgetUsed: 0, + retrievalLatencyMs: 0, + warnings: [warning], + }; + } +} diff --git a/apps/claw-frontend/src/app/(portal)/chat/[threadId]/page.tsx b/apps/claw-frontend/src/app/(portal)/chat/[threadId]/page.tsx index bd2117f7..6f9c6bd8 100644 --- a/apps/claw-frontend/src/app/(portal)/chat/[threadId]/page.tsx +++ b/apps/claw-frontend/src/app/(portal)/chat/[threadId]/page.tsx @@ -152,6 +152,10 @@ export default function ThreadDetailPage() { onQualityThresholdChange={threadSettings.setQualityThreshold} maxReRouteAttempts={threadSettings.maxReRouteAttempts} onMaxReRouteAttemptsChange={threadSettings.setMaxReRouteAttempts} + useMemory={threadSettings.useMemory} + onUseMemoryChange={threadSettings.setUseMemory} + useContext={threadSettings.useContext} + onUseContextChange={threadSettings.setUseContext} onSave={threadSettings.handleSave} isPending={threadSettings.isPending} /> @@ -196,6 +200,7 @@ export default function ThreadDetailPage() { isPending={isSending} selectedModel={threadSettings.selectedModel} onModelChange={threadSettings.handleModelChange} + threadId={threadId} />
diff --git a/apps/claw-frontend/src/components/chat/__tests__/message-bubble.test.tsx b/apps/claw-frontend/src/components/chat/__tests__/message-bubble.test.tsx index 74e7890d..44045d46 100644 --- a/apps/claw-frontend/src/components/chat/__tests__/message-bubble.test.tsx +++ b/apps/claw-frontend/src/components/chat/__tests__/message-bubble.test.tsx @@ -33,6 +33,10 @@ vi.mock('@/components/chat/routing-transparency', () => ({ RoutingTransparency: () =>
routing
, })); +vi.mock('@/components/chat/context-receipt-button', () => ({ + ContextReceiptButton: () =>
context-receipt
, +})); + vi.mock('@/lib/markdown', () => ({ MarkdownRenderer: ({ content }: { content: string }) =>
{content}
, })); diff --git a/apps/claw-frontend/src/components/chat/context-receipt-button.tsx b/apps/claw-frontend/src/components/chat/context-receipt-button.tsx new file mode 100644 index 00000000..b60f02f4 --- /dev/null +++ b/apps/claw-frontend/src/components/chat/context-receipt-button.tsx @@ -0,0 +1,139 @@ +'use client'; + +import { Info } from 'lucide-react'; +import { useState } from 'react'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { useContextReceipt } from '@/hooks/chat/use-context-receipt'; +import { useTranslation } from '@/lib/i18n'; +import type { ContextReceiptButtonProps } from '@/types/context-receipt-component.types'; + +export function ContextReceiptButton(props: ContextReceiptButtonProps): React.ReactElement { + const { messageId } = props; + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const { receipt, isLoading, isError } = useContextReceipt(messageId, open); + + return ( + + + + + + + {t('receipt.dialogTitle')} + {t('receipt.dialogDescription')} + + {isLoading &&

{t('receipt.loading')}

} + {isError &&

{t('receipt.notAvailable')}

} + {receipt && ( +
+
+ + {t('receipt.memoriesCount', { value: String(receipt.memories.length) })} + + + {t('receipt.packItemsCount', { value: String(receipt.packItems.length) })} + + + {t('receipt.tokenBudget', { + used: String(receipt.tokenBudgetUsed), + total: String(receipt.tokenBudget), + })} + +
+ + {receipt.memories.length > 0 && ( +
+

+ {t('receipt.memoriesHeading')} +

+
    + {receipt.memories.map((m) => ( +
  • +
    + + {m.type} + + + {m.reason} + + {m.sensitivity !== 'NORMAL' && ( + + {m.sensitivity} + + )} + + {t('receipt.scoreLabel', { value: m.score.toFixed(2) })} + +
    +

    {m.content ?? t('receipt.redactedPlaceholder')}

    +
  • + ))} +
+
+ )} + + {receipt.packItems.length > 0 && ( +
+

+ {t('receipt.packItemsHeading')} +

+
    + {receipt.packItems.map((p) => ( +
  • +
    + + {p.itemType} + + + {p.reason} + + {p.pinned && ( + + {t('receipt.pinnedBadge')} + + )} +
    +

    {p.content}

    +
  • + ))} +
+
+ )} + + {receipt.warnings.length > 0 && ( +
+

+ {t('receipt.warningsHeading')} +

+
    + {receipt.warnings.map((w) => ( +
  • • {w}
  • + ))} +
+
+ )} +
+ )} +
+
+ ); +} diff --git a/apps/claw-frontend/src/components/chat/message-bubble.tsx b/apps/claw-frontend/src/components/chat/message-bubble.tsx index 622575ab..b13bc08f 100644 --- a/apps/claw-frontend/src/components/chat/message-bubble.tsx +++ b/apps/claw-frontend/src/components/chat/message-bubble.tsx @@ -9,6 +9,7 @@ import { ThumbsUp, } from 'lucide-react'; +import { ContextReceiptButton } from '@/components/chat/context-receipt-button'; import { FileGenerationBubble } from '@/components/chat/file-generation-bubble'; import { ImageGenerationBubble } from '@/components/chat/image-generation-bubble'; import { JudgeRefereeDetails } from '@/components/chat/judge-referee-details'; @@ -231,6 +232,7 @@ export function MessageBubble({ ) : null} {!isUser ? (
+ {onRegenerate ? (