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..0e57b818 100644 --- a/apps/claw-chat-service/src/app/app.module.ts +++ b/apps/claw-chat-service/src/app/app.module.ts @@ -16,6 +16,8 @@ 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'; +import { ContextPreviewModule } from '../modules/context-preview/context-preview.module'; @Module({ imports: [ @@ -61,6 +63,8 @@ import { ChatMessagesModule } from '../modules/chat-messages/chat-messages.modul HealthModule, ChatThreadsModule, ChatMessagesModule, + ContextReceiptsModule, + ContextPreviewModule, 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/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-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)/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/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) => ( + + ))} +
+ )} +
+ + + + + + + + +
({ 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 ? (