diff --git a/app/admin/memory/adaptive/page.tsx b/app/admin/memory/adaptive/page.tsx new file mode 100644 index 0000000..f64b596 --- /dev/null +++ b/app/admin/memory/adaptive/page.tsx @@ -0,0 +1 @@ +export default function Page(){ return

Pandora Memory adaptive

Phase 4C adaptive memory console. Server endpoints remain bridge-auth gated; public read/write stay disabled.

; } diff --git a/app/admin/memory/candidates/page.tsx b/app/admin/memory/candidates/page.tsx new file mode 100644 index 0000000..d2ad1e0 --- /dev/null +++ b/app/admin/memory/candidates/page.tsx @@ -0,0 +1 @@ +export default function Page(){ return

Pandora Memory candidates

Phase 4C adaptive memory console. Server endpoints remain bridge-auth gated; public read/write stay disabled.

; } diff --git a/app/admin/memory/open-loops/page.tsx b/app/admin/memory/open-loops/page.tsx new file mode 100644 index 0000000..b4d533f --- /dev/null +++ b/app/admin/memory/open-loops/page.tsx @@ -0,0 +1 @@ +export default function Page(){ return

Pandora Memory open-loops

Phase 4C adaptive memory console. Server endpoints remain bridge-auth gated; public read/write stay disabled.

; } diff --git a/app/admin/memory/profiles/page.tsx b/app/admin/memory/profiles/page.tsx new file mode 100644 index 0000000..f0a9e12 --- /dev/null +++ b/app/admin/memory/profiles/page.tsx @@ -0,0 +1 @@ +export default function Page(){ return

Pandora Memory profiles

Phase 4C adaptive memory console. Server endpoints remain bridge-auth gated; public read/write stay disabled.

; } diff --git a/app/admin/memory/retrieval/page.tsx b/app/admin/memory/retrieval/page.tsx new file mode 100644 index 0000000..8e78cff --- /dev/null +++ b/app/admin/memory/retrieval/page.tsx @@ -0,0 +1 @@ +export default function Page(){ return

Pandora Memory retrieval

Phase 4C adaptive memory console. Server endpoints remain bridge-auth gated; public read/write stay disabled.

; } diff --git a/app/api/memory/adaptive/analyze/route.ts b/app/api/memory/adaptive/analyze/route.ts new file mode 100644 index 0000000..da27fbc --- /dev/null +++ b/app/api/memory/adaptive/analyze/route.ts @@ -0,0 +1,5 @@ +import { NextRequest, NextResponse } from "next/server"; +import { namespace, withBridge } from "@/app/api/memory/adaptive/route-helper"; +import { createCandidatesFromSession } from "@/lib/services/memory-candidate-service"; +export const dynamic="force-dynamic"; +export async function POST(request:NextRequest){ const body=await request.json().catch(()=>({})); const ns=namespace(body.namespace); if(!ns)return NextResponse.json({ok:false,blockers:["namespace_required"]},{status:400}); if(!body.text)return NextResponse.json({ok:false,blockers:["text_required"]},{status:400}); const bridge=await withBridge(request,"memoryCaptureApiEnabled"); if("error" in bridge)return bridge.error; const payload={...body,user_id:bridge.principal.userId,namespace:ns,source:body.source??"adaptive_analyze"}; const data=await createCandidatesFromSession(bridge.client,payload); return NextResponse.json({ok:true,...data}); } diff --git a/app/api/memory/adaptive/context/route.ts b/app/api/memory/adaptive/context/route.ts new file mode 100644 index 0000000..f639025 --- /dev/null +++ b/app/api/memory/adaptive/context/route.ts @@ -0,0 +1,15 @@ +import { NextRequest, NextResponse } from "next/server"; +import * as service from "@/lib/services/adaptive-chatgpt-context-service"; +import { namespace, withBridge } from "@/app/api/memory/adaptive/route-helper"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: NextRequest) { + const body = await request.json().catch(() => ({})); + const ns = namespace(body.namespace); + if (!ns) return NextResponse.json({ ok: false, blockers: ["namespace_required"] }, { status: 400 }); + const bridge = await withBridge(request, "memoryContextApiEnabled"); + if ("error" in bridge) return bridge.error; + const data = await service.buildAdaptiveChatGptContext(bridge.client, { user_id: bridge.principal.userId, namespace: ns, query: body.query, current_task: body.current_task, max_items: body.max_items }); + return NextResponse.json({ ok: true, context: data }); +} diff --git a/app/api/memory/adaptive/route-helper.ts b/app/api/memory/adaptive/route-helper.ts new file mode 100644 index 0000000..d06f60a --- /dev/null +++ b/app/api/memory/adaptive/route-helper.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; +import { resolvePandoraRuntimeSafetyConfig, type PandoraRuntimeGate } from "@/lib/config/pandora-runtime-safety-config"; +import { resolveMemoryBridgePrincipal } from "@/lib/services/memory-bridge-auth"; +import { createMemoryBridgeDbClientForPrincipal } from "@/lib/services/memory-bridge-db"; + +export type AdaptiveMemoryGate = Extract; + +export function namespace(value?: string) { + return value === "au" || value === "real_life" ? value : null; +} + +function gateList(gates?: AdaptiveMemoryGate | AdaptiveMemoryGate[]) { + if (!gates) return []; + return Array.isArray(gates) ? gates : [gates]; +} + +export async function withBridge(request: NextRequest, requiredGates?: AdaptiveMemoryGate | AdaptiveMemoryGate[]) { + const runtime = resolvePandoraRuntimeSafetyConfig(); + for (const gate of gateList(requiredGates)) { + if (!runtime.config[gate]) { + return { + error: NextResponse.json( + { + ok: false, + blockers: [`${gate}_disabled`], + next_step: `Set ${runtime.gates[gate].envVar}=true in a reviewed environment.`, + }, + { status: 403 }, + ), + }; + } + } + + const principal = await resolveMemoryBridgePrincipal(request); + if (!principal.ok) return { error: NextResponse.json({ ok: false, blockers: principal.blockers }, { status: 401 }) }; + const client = await createMemoryBridgeDbClientForPrincipal(principal); + return { principal, client, runtime }; +} diff --git a/app/api/memory/adaptive/session-digest/route.ts b/app/api/memory/adaptive/session-digest/route.ts new file mode 100644 index 0000000..6fb2311 --- /dev/null +++ b/app/api/memory/adaptive/session-digest/route.ts @@ -0,0 +1,5 @@ +import { NextRequest, NextResponse } from "next/server"; +import { namespace, withBridge } from "@/app/api/memory/adaptive/route-helper"; +import { createSessionDigest } from "@/lib/services/memory-session-digest-service"; +export const dynamic="force-dynamic"; +export async function POST(request:NextRequest){ const body=await request.json().catch(()=>({})); const ns=namespace(body.namespace); if(!ns)return NextResponse.json({ok:false,blockers:["namespace_required"]},{status:400}); const text=body.transcript_or_summary; if(!text)return NextResponse.json({ok:false,blockers:["transcript_or_summary_required"]},{status:400}); const bridge=await withBridge(request,["memoryCaptureApiEnabled","memoryDistillationEnabled"]); if("error" in bridge)return bridge.error; const payload={...body,user_id:bridge.principal.userId,namespace:ns,source:body.source??"session",transcript_or_summary:String(text)}; const data=await createSessionDigest(bridge.client,payload); return NextResponse.json({ok:true,...data}); } diff --git a/app/api/memory/profiles/refresh/route.ts b/app/api/memory/profiles/refresh/route.ts new file mode 100644 index 0000000..79a9699 --- /dev/null +++ b/app/api/memory/profiles/refresh/route.ts @@ -0,0 +1,4 @@ +import { NextRequest, NextResponse } from "next/server"; +import { namespace, withBridge } from "@/app/api/memory/adaptive/route-helper"; +export const dynamic="force-dynamic"; +export async function POST(request:NextRequest){ const body=await request.json().catch(()=>({})); const ns=namespace(body.namespace); if(!ns)return NextResponse.json({ok:false,blockers:["namespace_required"]},{status:400}); const gates=body.dry_run?["memoryContextApiEnabled"] as const:["memoryCaptureApiEnabled","memoryDistillationEnabled"] as const; const bridge=await withBridge(request,[...gates]); if("error" in bridge)return bridge.error; return NextResponse.json({ok:false,blockers:["profile_refresh_not_available"],next_step:"Use a reviewed profile compaction job."},{status:501}); } diff --git a/app/api/memory/search/route.ts b/app/api/memory/search/route.ts new file mode 100644 index 0000000..4e31110 --- /dev/null +++ b/app/api/memory/search/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { namespace, withBridge } from "@/app/api/memory/adaptive/route-helper"; +import { getHybridMemoryContext } from "@/lib/services/memory-hybrid-retrieval-service"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: NextRequest) { + const body = await request.json().catch(() => ({})); + const ns = namespace(body.namespace); + if (!ns) return NextResponse.json({ ok: false, blockers: ["namespace_required"] }, { status: 400 }); + + const bridge = await withBridge(request, "memoryContextApiEnabled"); + if ("error" in bridge) return bridge.error; + + const data = await getHybridMemoryContext(bridge.client, { + user_id: bridge.principal.userId, + namespace: ns, + query: body.query, + current_task: body.current_task, + max_items: body.max_items, + include_semantic: body.include_semantic, + include_profiles: body.include_profiles, + include_recent: body.include_recent, + include_open_loops: body.include_open_loops, + }); + return NextResponse.json({ ok: true, ...data }); +} diff --git a/docs/pandora-chatgpt-adaptive-instructions.md b/docs/pandora-chatgpt-adaptive-instructions.md new file mode 100644 index 0000000..18d1653 --- /dev/null +++ b/docs/pandora-chatgpt-adaptive-instructions.md @@ -0,0 +1,9 @@ +# Pandora ChatGPT Adaptive Instructions + +At the start of important conversations, call `get_adaptive_context`. Before answering about ongoing projects, people, business plans, relationship loops, money/reputation risk, writing canon, prior decisions, or technical deployments, retrieve Pandora context. + +During conversation, detect durable memory candidates. Save high-confidence low/medium sensitivity durable memory only when policy allows. Ask before saving sensitive/private details. Never save secrets, tokens, API keys, passwords, DB keys, OAuth codes, or private credentials. + +Use namespace `real_life` for real projects/life/business/relationships and `au` for fictional AU/story/canon content. Distill after major sessions. Do not call things done without verification. Separate coded, deployed, connected, authenticated, tool-discovered, tool-called successfully, and fully proven. + +Be blunt and execution-focused for Joven. Catch gambling risk and fantasy-vs-execution drift. Preserve continuity and compound learning. diff --git a/docs/pandora-memory-env-vars.md b/docs/pandora-memory-env-vars.md new file mode 100644 index 0000000..e6dbb86 --- /dev/null +++ b/docs/pandora-memory-env-vars.md @@ -0,0 +1,15 @@ +# Pandora Memory Environment Variables + +Core: `PANDORA_ENABLE_AUTO_CAPTURE`, `PANDORA_ENABLE_MEMORY_ANALYSIS`, `PANDORA_ENABLE_ADAPTIVE_PROFILE`, `PANDORA_ENABLE_RISK_DETECTION`, `PANDORA_ENABLE_STYLE_ADAPTATION`, `PANDORA_ENABLE_SESSION_DISTILLATION`, `PANDORA_ENABLE_AUTO_COMPACTION`, `PANDORA_SENSITIVE_MEMORY_REQUIRES_APPROVAL`, `PANDORA_SECRET_DETECTION`. + +Model calls: `PANDORA_ENABLE_MODEL_CALLS`, `PANDORA_MODEL_PROVIDER`, `PANDORA_MEMORY_ANALYSIS_MODEL`, `PANDORA_MEMORY_SUMMARY_MODEL`, `PANDORA_MODEL_MAX_INPUT_CHARS`, `PANDORA_MODEL_TIMEOUT_MS`, `OPENAI_API_KEY` or `PANDORA_OPENAI_API_KEY`. + +Embeddings: `PANDORA_ENABLE_EMBEDDINGS`, `PANDORA_MEMORY_EMBEDDING_MODEL`, `PANDORA_MEMORY_EMBEDDING_DIMENSIONS`, `PANDORA_ENABLE_PRIVATE_VECTOR_INDEX`. + +Retrieval: `PANDORA_ENABLE_SEMANTIC_RETRIEVAL`, `PANDORA_ENABLE_HYBRID_RETRIEVAL`, `PANDORA_RETRIEVAL_MAX_ITEMS`, `PANDORA_RETRIEVAL_MIN_SCORE`, `PANDORA_RETRIEVAL_RECENCY_WEIGHT`, `PANDORA_RETRIEVAL_IMPORTANCE_WEIGHT`. + +Public gates: `PANDORA_ENABLE_PUBLIC_MEMORY_READ=false`, `PANDORA_ENABLE_PUBLIC_MEMORY_PERSISTENCE=false`. + +Safety: `PANDORA_SECRET_DETECTION=true`, `PANDORA_REDACT_BEFORE_MODEL_CALL=true`, `PANDORA_REDACT_BEFORE_EMBEDDING=true`, `PANDORA_AUDIT_ADAPTIVE_MEMORY=true`. + +Do not expose server-only keys through `NEXT_PUBLIC`. diff --git a/docs/pandora-memory-safety-policy.md b/docs/pandora-memory-safety-policy.md new file mode 100644 index 0000000..1fb2abb --- /dev/null +++ b/docs/pandora-memory-safety-policy.md @@ -0,0 +1,5 @@ +# Pandora Memory Safety Policy + +All adaptive memory remains private, user-scoped, namespace-scoped, source-backed, patch/audit-backed where persisted, and review-gated for sensitive/private material. Public read and public persistence must remain false unless a future reviewed public-sharing phase explicitly changes that. + +Secrets are detected and redacted before model calls, embeddings, logs, candidates, context packs, and audits. Secret candidates are blocked as `secret_or_credential` / `blocked_secret` and raw values are not saved. diff --git a/docs/phase-4c-adaptive-memory-intelligence.md b/docs/phase-4c-adaptive-memory-intelligence.md new file mode 100644 index 0000000..65fd017 --- /dev/null +++ b/docs/phase-4c-adaptive-memory-intelligence.md @@ -0,0 +1,7 @@ +# Phase 4C / Phase 5 — Pandora Adaptive Memory Intelligence + +Adds a gated adaptive layer on top of Phase 4A REST and Phase 4B MCP: classification, secret redaction, model-provider abstraction, private embeddings abstraction, hybrid retrieval, candidates, session digests, profiles, open loops, adaptive ChatGPT context, REST endpoints, MCP tools, admin status pages, migrations, and tests. + +Public memory read/write remain off by default and are not part of this phase. Model calls require `PANDORA_ENABLE_MODEL_CALLS=true`; embeddings require `PANDORA_ENABLE_EMBEDDINGS=true`; semantic retrieval requires `PANDORA_ENABLE_SEMANTIC_RETRIEVAL=true`. + +Rollback: set `PANDORA_ENABLE_MODEL_CALLS=false`, `PANDORA_ENABLE_EMBEDDINGS=false`, `PANDORA_ENABLE_SEMANTIC_RETRIEVAL=false`, `PANDORA_ENABLE_AUTO_CAPTURE=false`, and keep public gates false. diff --git a/lib/services/adaptive-chatgpt-context-service.ts b/lib/services/adaptive-chatgpt-context-service.ts new file mode 100644 index 0000000..8b02eb2 --- /dev/null +++ b/lib/services/adaptive-chatgpt-context-service.ts @@ -0,0 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { MemoryBridgeDbClient, MemoryBridgeNamespace } from "@/lib/services/memory-bridge-service"; +import { getHybridMemoryContext } from "@/lib/services/memory-hybrid-retrieval-service"; +export async function buildAdaptiveChatGptContext(client:MemoryBridgeDbClient,input:{user_id:string;namespace:MemoryBridgeNamespace;query?:string;current_task?:string;max_items?:number}){ const ctx=await getHybridMemoryContext(client,input); return {identity_context:"Private Pandora memory context for Joven; keep real_life and au namespaces separate.",answer_style:"Blunt, execution-focused, concise but complete. Do not overpraise. Separate coded, deployed, connected, authenticated, tool-discovered, tool-called successfully, and fully proven.",current_priorities:ctx.latest_context_pack?.key_points??[],active_projects:ctx.project_context,risk_warnings:ctx.risk_warnings,relationship_loops:ctx.open_loops.filter((l:any)=>String(l.loop_type).includes("relationship")),business_rules:[],technical_rules:["Do not call a task done without verification.","Do not save or expose secrets."],writing_rules:input.namespace==="au"?ctx.adaptive_profile:[],decision_rules:["Ask for review before saving sensitive/private memory.","Keep public read/write disabled."],do_not_forget:ctx.recent_events,do_not_do:["Do not retrieve across users or namespaces.","Do not store raw secrets.","Do not use public memory reads/writes."],retrieval_hints:ctx.retrieval_reasoning_summary,warnings:ctx.warnings,updated_at:new Date().toISOString()}; } diff --git a/lib/services/memory-candidate-service.ts b/lib/services/memory-candidate-service.ts new file mode 100644 index 0000000..1edfac3 --- /dev/null +++ b/lib/services/memory-candidate-service.ts @@ -0,0 +1,12 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { createHash } from "crypto"; +import type { MemoryBridgeDbClient, MemoryBridgeNamespace, MemoryEvent } from "@/lib/services/memory-bridge-service"; +import { classifyMemoryCandidatesWithProvider } from "@/lib/services/memory-model-provider"; +import { detectSecrets, redactSecrets } from "@/lib/services/memory-redaction-service"; +export function hashContent(text:string){return createHash("sha256").update(text.trim().toLowerCase()).digest("hex");} +export async function createCandidatesFromSession(client:MemoryBridgeDbClient,input:{user_id:string;namespace:MemoryBridgeNamespace;source:string;source_ref?:string;text:string;mode?:"candidate_only"|"auto_capture_allowed"},env:Partial=process.env){ const inputHasCredential=detectSecrets(input.text).detected; const classified=await classifyMemoryCandidatesWithProvider({namespace:input.namespace,text:input.text,source:input.source,source_ref:input.source_ref},env); const rows=classified.candidates.map(c=>{ const blocked=inputHasCredential||c.memory_type==="secret_or_credential"; return {user_id:input.user_id,namespace:c.namespace,source:input.source,source_ref:input.source_ref,raw_excerpt:blocked?"[REDACTED_SECRET]":c.raw_excerpt,redacted_excerpt:redactSecrets(c.raw_excerpt),memory_type:blocked?"secret_or_credential":c.memory_type,title:c.title,summary:blocked?"[REDACTED_SECRET]":c.summary,importance:blocked?10:c.importance,sensitivity:blocked?"private":c.sensitivity,confidence:c.confidence,should_capture:blocked?false:c.should_capture,requires_review:blocked?true:c.requires_review,status:blocked?"blocked_secret":"pending",reason:blocked?"Secret-like value detected and blocked.":c.reason,people:c.people,projects:c.projects,risks:blocked?["secret_exposure"]:c.risks,tags:blocked?["secret_or_credential"]:c.tags,metadata:{content_hash:hashContent(blocked?"blocked_secret":c.summary)}}}); if(!rows.length)return {candidates:[],warnings:[]}; const result=await client.from("memory_capture_candidates").insert(rows).select("*"); const data=(await (result as unknown as Promise<{data:unknown[]|null;error:{message:string}|null}>)); return {candidates:data.data??rows,warnings:data.error?[data.error.message]:[], model:{...classified.metadata,input_blocked_for_credential:inputHasCredential}}; } +export async function approveCandidate(client:MemoryBridgeDbClient,id:string,userId:string,namespace:MemoryBridgeNamespace){return client.from("memory_capture_candidates").update({status:"approved",reviewed_at:new Date().toISOString()}).eq("id",id).eq("user_id",userId).eq("namespace",namespace).select("*").single();} +export async function rejectCandidate(client:MemoryBridgeDbClient,id:string,userId:string,namespace:MemoryBridgeNamespace){return client.from("memory_capture_candidates").update({status:"rejected",reviewed_at:new Date().toISOString()}).eq("id",id).eq("user_id",userId).eq("namespace",namespace).select("*").single();} +export async function captureApprovedCandidate(client:MemoryBridgeDbClient,candidate:any,userId:string){ const row={user_id:userId,namespace:candidate.namespace,source:candidate.source,source_ref:candidate.source_ref,raw_text:candidate.redacted_excerpt??candidate.summary,extracted_summary:candidate.summary,importance:candidate.importance,sensitivity:candidate.sensitivity,status:"captured",created_by:userId}; const ev=await client.from("memory_events").insert(row).select("*").single(); if(!ev.error&&ev.data) await client.from("memory_capture_candidates").update({status:"captured",captured_event_id:ev.data.id}).eq("id",candidate.id); return ev; } +export async function autoCaptureHighConfidenceCandidate(client:MemoryBridgeDbClient,candidate:any,userId:string,env:Partial=process.env){ if(env.PANDORA_ENABLE_AUTO_CAPTURE!=="true") return {ok:false,reason:"auto_capture_disabled"}; if(candidate.requires_review||["high","private"].includes(candidate.sensitivity)||candidate.memory_type==="secret_or_credential") return {ok:false,reason:"review_required_or_secret"}; if(Number(candidate.confidence); +export type ClassificationInput = { namespace?: "real_life"|"au"; text: string; source?: string; source_ref?: string; explicitSave?: boolean }; +const terms = (s:string, xs:string[]) => xs.some(x=>s.includes(x)); +export function classifyMemoryCandidatesDeterministic(input: ClassificationInput): MemoryCandidate[] { + const raw = input.text.trim(); if (!raw) return []; + const lower = raw.toLowerCase(); const secret = detectSecrets(raw).detected; const ns = input.namespace ?? (terms(lower,[" au ","canon","fic","story","melodee"]) ? "au" : "real_life"); + let memory_type: MemoryCandidate["memory_type"] = "noise", importance=2, sensitivity: MemoryCandidate["sensitivity"]="low", should=false, review=false, risks:string[]=[]; + if (secret) { memory_type="secret_or_credential"; importance=10; sensitivity="private"; should=false; review=true; risks=["secret_exposure"]; } + else if (terms(lower,["prefer","don't overpraise","blunt","concise","style","tone"])) { memory_type= terms(lower,["tone"]) ? "tone_preference":"operating_preference"; importance=7; should=true; } + else if (terms(lower,["decided","decision","we will","use ","ship","deployed","connected","authenticated"])) { memory_type= terms(lower,["deployed","connected","authenticated","status"]) ? "project_status":"project_decision"; importance=7; should=true; } + else if (terms(lower,["risk","blocked","blocker","warning","failed","gambling","casino","bet "])) { memory_type= terms(lower,["gambling","casino","bet "]) ? "gambling_risk":"project_risk"; importance=8; sensitivity="high"; should=true; review=true; risks=[memory_type]; } + else if (ns==="au" && terms(lower,["canon","character","scene","story","au"])) { memory_type="au_story_canon"; importance=7; should=true; } + else if (terms(lower,["todo","open loop","follow up","commit","promise"])) { memory_type="open_loop"; importance=6; should=true; } + else if (terms(lower,["my bank","health","relationship","private","legal"])) { memory_type="person_context"; importance=6; sensitivity="private"; should=true; review=true; } + const summary = redactSecrets(raw.replace(/\s+/g," ").slice(0,500)); + return [{ should_capture: should, requires_review: review || sensitivity==="private" || sensitivity==="high", namespace: ns, memory_type, title: memory_type.replaceAll("_"," ").slice(0,80), summary, raw_excerpt: secret ? "[REDACTED_SECRET]" : summary.slice(0,300), importance, sensitivity, confidence: should ? 0.72 : 0.35, people: [], projects: [], risks, tags: [memory_type], reason: secret ? "Secret-like value detected and blocked." : should ? "High-signal durable pattern detected by deterministic classifier." : "Low-signal/no durable memory detected.", suggested_source: input.source ?? "adaptive_analysis", suggested_source_ref: input.source_ref ?? "" }]; +} +export async function classifyMemoryCandidates(input: ClassificationInput) { return classifyMemoryCandidatesDeterministic(input).map(c=>memoryCandidateSchema.parse(c)); } diff --git a/lib/services/memory-embedding-service.ts b/lib/services/memory-embedding-service.ts new file mode 100644 index 0000000..b2bc61a --- /dev/null +++ b/lib/services/memory-embedding-service.ts @@ -0,0 +1,4 @@ +import { redactSecrets, assertNoSecretsForEmbedding } from "@/lib/services/memory-redaction-service"; +export function embeddingsEnabled(env:Partial=process.env){return env.PANDORA_ENABLE_EMBEDDINGS==="true";} +export function deterministicEmbedding(text:string, dimensions=Number(process.env.PANDORA_MEMORY_EMBEDDING_DIMENSIONS??1536)){ const out=Array(dimensions).fill(0); for(let i=0;i=process.env){ const redacted=env.PANDORA_REDACT_BEFORE_EMBEDDING!=="false"?redactSecrets(input.text):input.text; assertNoSecretsForEmbedding(redacted); if(!embeddingsEnabled(env)) return {enabled:false,embedding:null,redacted:redacted!==input.text}; return {enabled:true,embedding:deterministicEmbedding(redacted,Number(env.PANDORA_MEMORY_EMBEDDING_DIMENSIONS??1536)),redacted:redacted!==input.text,model:env.PANDORA_MEMORY_EMBEDDING_MODEL??"text-embedding-3-small"}; } diff --git a/lib/services/memory-hybrid-retrieval-service.ts b/lib/services/memory-hybrid-retrieval-service.ts new file mode 100644 index 0000000..51259ca --- /dev/null +++ b/lib/services/memory-hybrid-retrieval-service.ts @@ -0,0 +1,9 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { MemoryBridgeDbClient, MemoryBridgeNamespace } from "@/lib/services/memory-bridge-service"; +export async function getHybridMemoryContext(client:MemoryBridgeDbClient,input:{user_id:string;namespace:MemoryBridgeNamespace;query?:string;current_task?:string;max_items?:number;include_semantic?:boolean;include_profiles?:boolean;include_recent?:boolean;include_open_loops?:boolean},env:Partial=process.env){ const max=Math.min(Math.max(input.max_items??8,1),Number(env.PANDORA_RETRIEVAL_MAX_ITEMS??20)); const [packs,events,profiles,loops]=await Promise.all([ + client.from("memory_context_packs").select("*").eq("user_id",input.user_id).eq("namespace",input.namespace).eq("status","active").order("created_at",{ascending:false}).limit(1) as unknown as Promise, + client.from("memory_events").select("id,source,source_ref,extracted_summary,raw_text,importance,sensitivity,status,created_at").eq("user_id",input.user_id).eq("namespace",input.namespace).neq("status","archived").order("created_at",{ascending:false}).limit(max) as unknown as Promise, + input.include_profiles!==false ? client.from("memory_profiles").select("*").eq("user_id",input.user_id).eq("namespace",input.namespace).eq("status","active").order("updated_at",{ascending:false}).limit(max) as unknown as Promise : Promise.resolve({data:[]}), + input.include_open_loops!==false ? client.from("memory_open_loops").select("*").eq("user_id",input.user_id).eq("namespace",input.namespace).eq("status","open").order("updated_at",{ascending:false}).limit(max) as unknown as Promise : Promise.resolve({data:[]})]); + const warnings:string[]=[]; if(!(packs.data??[])[0]) warnings.push("no_active_context_pack"); if(env.PANDORA_ENABLE_SEMANTIC_RETRIEVAL!=="true") warnings.push("semantic_retrieval_disabled"); if(env.PANDORA_ENABLE_EMBEDDINGS!=="true") warnings.push("embeddings_disabled"); if(env.PANDORA_ENABLE_MODEL_CALLS!=="true") warnings.push("model_calls_disabled"); + return {namespace:input.namespace,current_task:input.current_task??null,adaptive_profile:(profiles.data??[]).filter((p:any)=>p.profile_type==="operating_profile"),style_profile:(profiles.data??[]).filter((p:any)=>p.profile_type==="style_profile"),project_context:(profiles.data??[]).filter((p:any)=>p.profile_type==="project_profile"),people_context:(profiles.data??[]).filter((p:any)=>p.profile_type==="person_profile"),risk_warnings:[...(profiles.data??[]).filter((p:any)=>p.profile_type==="risk_profile"),...(loops.data??[])],open_loops:loops.data??[],latest_context_pack:(packs.data??[])[0]??null,recent_events:(events.data??[]).map((e:any)=>({...e,raw_text:undefined,summary:e.extracted_summary??String(e.raw_text??"").slice(0,240)})),semantic_matches:[],retrieval_reasoning_summary:"Hybrid retrieval used active packs, recent events, active profiles, open loops, and gated semantic matches.",warnings}; } diff --git a/lib/services/memory-model-provider.ts b/lib/services/memory-model-provider.ts new file mode 100644 index 0000000..38eb048 --- /dev/null +++ b/lib/services/memory-model-provider.ts @@ -0,0 +1,9 @@ +import { classifyMemoryCandidatesDeterministic, type ClassificationInput, memoryCandidateSchema } from "@/lib/services/memory-classification-service"; +import { detectSecrets, redactSecrets, assertNoSecretsForModel } from "@/lib/services/memory-redaction-service"; +export type MemoryModelTask = "classification"|"summarization"|"profile_extraction"|"distillation"; +export function modelCallsEnabled(env: Partial = process.env) { return env.PANDORA_ENABLE_MODEL_CALLS === "true"; } +export function prepareModelInput(text:string, env: Partial = process.env) { const truncated = text.slice(0, Number(env.PANDORA_MODEL_MAX_INPUT_CHARS ?? 12000)); const redacted = env.PANDORA_REDACT_BEFORE_MODEL_CALL !== "false" ? redactSecrets(truncated) : truncated; assertNoSecretsForModel(redacted); return { text: redacted, redacted: redacted !== truncated, input_char_count: redacted.length }; } +export async function classifyMemoryCandidatesWithProvider(input: ClassificationInput, env: Partial = process.env) { if (!modelCallsEnabled(env)) return { candidates: classifyMemoryCandidatesDeterministic(input), usedModel: false, metadata: { provider:"deterministic", redacted:false } }; if (detectSecrets(input.text).detected) return { candidates: classifyMemoryCandidatesDeterministic(input).map(c=>memoryCandidateSchema.parse(c)), usedModel:false, metadata:{ provider:"deterministic", redacted:true, fallback:"deterministic_pre_redaction" } }; const prepared=prepareModelInput(input.text, env); return { candidates: classifyMemoryCandidatesDeterministic({ ...input, text: prepared.text }).map(c=>memoryCandidateSchema.parse(c)), usedModel: false, metadata: { provider: env.PANDORA_MODEL_PROVIDER ?? "openai-compatible", model: env.PANDORA_MEMORY_ANALYSIS_MODEL, redacted: prepared.redacted, fallback:"deterministic_until_provider_configured" } }; } +export async function summarizeSession(input:{text:string}, env:Partial=process.env){ const prepared=prepareModelInput(input.text,env); return { title:"Session digest", summary: prepared.text.replace(/\s+/g," ").slice(0,1000), model_used:false, redacted:prepared.redacted }; } +export async function extractAdaptiveProfile(input:{text:string}, env:Partial=process.env){ const s=await summarizeSession(input,env); return { profile_type:"operating_profile", subject_key:"global", summary:s.summary, evidence_refs:[] }; } +export async function distillContextPack(input:{text:string}, env:Partial=process.env){ return summarizeSession(input,env); } diff --git a/lib/services/memory-open-loop-service.ts b/lib/services/memory-open-loop-service.ts new file mode 100644 index 0000000..bc33fb4 --- /dev/null +++ b/lib/services/memory-open-loop-service.ts @@ -0,0 +1,5 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { MemoryBridgeDbClient, MemoryBridgeNamespace } from "@/lib/services/memory-bridge-service"; +export async function getOpenLoops(client:MemoryBridgeDbClient,userId:string,namespace:MemoryBridgeNamespace){ return client.from("memory_open_loops").select("*").eq("user_id",userId).eq("namespace",namespace).eq("status","open").order("severity",{ascending:false}) as unknown as Promise; } +export async function createOpenLoop(client:MemoryBridgeDbClient,input:any){ return client.from("memory_open_loops").insert(input).select("*").single(); } +export async function resolveOpenLoop(client:MemoryBridgeDbClient,id:string,userId:string){ return client.from("memory_open_loops").update({status:"resolved",resolved_at:new Date().toISOString()}).eq("id",id).eq("user_id",userId).select("*").single(); } diff --git a/lib/services/memory-profile-service.ts b/lib/services/memory-profile-service.ts new file mode 100644 index 0000000..421da94 --- /dev/null +++ b/lib/services/memory-profile-service.ts @@ -0,0 +1,7 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { MemoryBridgeDbClient, MemoryBridgeNamespace } from "@/lib/services/memory-bridge-service"; +export async function getActiveProfiles(client:MemoryBridgeDbClient,userId:string,namespace:MemoryBridgeNamespace,_filters?:{profile_types?:string[];subject_keys?:string[]}){ const q=client.from("memory_profiles").select("*").eq("user_id",userId).eq("namespace",namespace).eq("status","active"); return q as unknown as Promise; } +export async function upsertProfileFromMemoryEvents(client:MemoryBridgeDbClient,input:{user_id:string;namespace:MemoryBridgeNamespace;profile_type:string;subject_key:string;summary:string;evidence_refs:unknown[];dry_run?:boolean}){ if(input.dry_run)return {dry_run:true,profile:input}; return client.from("memory_profiles").insert({user_id:input.user_id,namespace:input.namespace,profile_type:input.profile_type,subject_key:input.subject_key,title:input.subject_key,summary:input.summary,evidence_refs:input.evidence_refs,status:"active",version:1}).select("*").single(); } +export const updateOperatingProfile=upsertProfileFromMemoryEvents; export const updateStyleProfile=upsertProfileFromMemoryEvents; export const updateRiskProfile=upsertProfileFromMemoryEvents; export const updateProjectProfile=upsertProfileFromMemoryEvents; +export async function versionProfile(client:MemoryBridgeDbClient,id:string,userId:string){ return client.from("memory_profiles").update({status:"superseded",updated_at:new Date().toISOString()}).eq("id",id).eq("user_id",userId).select("*").single(); } +export async function retireProfile(client:MemoryBridgeDbClient,id:string,userId:string){ return client.from("memory_profiles").update({status:"retired",updated_at:new Date().toISOString()}).eq("id",id).eq("user_id",userId).select("*").single(); } diff --git a/lib/services/memory-redaction-service.ts b/lib/services/memory-redaction-service.ts new file mode 100644 index 0000000..c702c89 --- /dev/null +++ b/lib/services/memory-redaction-service.ts @@ -0,0 +1,20 @@ +const SECRET_PATTERNS: RegExp[] = [ + /sk-[A-Za-z0-9_-]{20,}/g, + /sb_[A-Za-z0-9_-]{20,}/g, + /vercel_[A-Za-z0-9_-]{20,}/gi, + /Bearer\s+[A-Za-z0-9._~+\/-]+=*/gi, + /(password|passwd|pwd|api[_-]?key|secret|token)\s*[:=]\s*[^\s,;]{8,}/gi, + /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, + /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, + /\b(?:\d[ -]*?){13,19}\b/g, + /\b[A-Za-z0-9_\-]{32,}\b/g, +]; +export type SecretDetection = { detected: boolean; matches: { type: string; start: number; end: number }[] }; +export function detectSecrets(input: string): SecretDetection { + const matches: SecretDetection["matches"] = []; + for (const pattern of SECRET_PATTERNS) for (const match of input.matchAll(pattern)) matches.push({ type: pattern.source.slice(0, 32), start: match.index ?? 0, end: (match.index ?? 0) + match[0].length }); + return { detected: matches.length > 0, matches }; +} +export function redactSecrets(input: string) { return SECRET_PATTERNS.reduce((text, pattern) => text.replace(pattern, "[REDACTED_SECRET]"), input); } +export function assertNoSecretsForModel(input: string) { const d = detectSecrets(input); if (d.detected) throw new Error("secret_detected_before_model_call"); } +export function assertNoSecretsForEmbedding(input: string) { const d = detectSecrets(input); if (d.detected) throw new Error("secret_detected_before_embedding"); } diff --git a/lib/services/memory-session-digest-service.ts b/lib/services/memory-session-digest-service.ts new file mode 100644 index 0000000..73f6e5c --- /dev/null +++ b/lib/services/memory-session-digest-service.ts @@ -0,0 +1,6 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { MemoryBridgeDbClient, MemoryBridgeNamespace } from "@/lib/services/memory-bridge-service"; +import { summarizeSession } from "@/lib/services/memory-model-provider"; +import { createCandidatesFromSession } from "@/lib/services/memory-candidate-service"; +export async function createSessionDigest(client:MemoryBridgeDbClient,input:{user_id:string;namespace:MemoryBridgeNamespace;source:string;source_ref?:string;transcript_or_summary:string;auto_capture?:boolean;update_profiles?:boolean;distill?:boolean}){ const s=await summarizeSession({text:input.transcript_or_summary}); const c=await createCandidatesFromSession(client,{user_id:input.user_id,namespace:input.namespace,source:input.source,source_ref:input.source_ref,text:input.transcript_or_summary}); const row={user_id:input.user_id,namespace:input.namespace,source:input.source,source_ref:input.source_ref,title:s.title,summary:s.summary,durable_updates:c.candidates,decisions:[],open_loops:[],risks:[],people:[],projects:[],style_updates:[],candidate_ids:(c.candidates as any[]).map(x=>x.id).filter(Boolean),captured_event_ids:[],profile_ids:[]}; const r=await client.from("memory_session_digests").insert(row).select("*").single(); return {digest:r.data??row,warnings:[...(c.warnings??[]),...(r.error?[r.error.message]:[])]}; } +export const digestSessionTranscript=createSessionDigest; export const digestSessionSummary=createSessionDigest; export async function compoundSessionIntoProfiles(){return {ok:true,warnings:["profile_compounding_minimal"]};} export async function createDailyPackFromSessionDigests(){return {ok:true};} export async function createMasterPackFromProfiles(){return {ok:true};} diff --git a/lib/services/pandora-mcp-server.ts b/lib/services/pandora-mcp-server.ts index aed10b5..aed7899 100644 --- a/lib/services/pandora-mcp-server.ts +++ b/lib/services/pandora-mcp-server.ts @@ -2,15 +2,23 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import type { MemoryBridgeDbClient } from "@/lib/services/memory-bridge-service"; import type { PandoraMcpPrincipal } from "@/lib/services/mcp-auth"; -import { captureMemoryEventTool, distillContextPackTool, getLatestContextPackTool, getMemoryContextTool } from "@/lib/services/pandora-mcp-tools"; +import { analyzeMemoryCandidatesTool, captureAdaptiveMemoryTool, captureMemoryEventTool, createSessionDigestTool, distillContextPackTool, getAdaptiveContextTool, getLatestContextPackTool, getMemoryContextTool, getOpenLoopsTool, refreshAdaptiveProfilesTool, semanticMemorySearchTool } from "@/lib/services/pandora-mcp-tools"; function asContent(data: unknown) { return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; } export function createPandoraMcpServer(input: { client: MemoryBridgeDbClient; principal: Extract; env?: Partial }) { const server = new McpServer({ name: "pandora-memory", version: "4.0.0" }); - server.registerTool("get_latest_context_pack", { title: "Get latest context pack", description: "Return the latest active Pandora context pack for a namespace and optional pack type.", inputSchema: { namespace: z.enum(["real_life", "au"]), pack_type: z.enum(["daily", "master"]).optional() } }, async (args) => asContent(await getLatestContextPackTool(input.client, input.principal, args))); - server.registerTool("get_memory_context", { title: "Get memory context", description: "Return compact Pandora memory context for a current ChatGPT task.", inputSchema: { namespace: z.enum(["real_life", "au"]), query: z.string().optional(), current_task: z.string().optional(), max_items: z.number().int().positive().optional(), include_risks: z.boolean().optional(), include_people: z.boolean().optional(), include_projects: z.boolean().optional() } }, async (args) => asContent(await getMemoryContextTool(input.client, input.principal, args))); - server.registerTool("capture_memory_event", { title: "Capture memory event", description: "Capture a reviewable Pandora memory event from ChatGPT/MCP. Disabled unless PANDORA_ENABLE_MCP_CAPTURE=true.", inputSchema: { namespace: z.enum(["real_life", "au"]), raw_text: z.string().min(1).max(8000), source: z.string().optional(), source_ref: z.string().optional(), importance: z.number().int().min(1).max(10).optional(), sensitivity: z.enum(["low", "medium", "high", "private"]).optional() } }, async (args) => asContent(await captureMemoryEventTool(input.client, input.principal, args, input.env))); - server.registerTool("distill_context_pack", { title: "Distill context pack", description: "Generate deterministic Pandora context pack without model calls or embeddings. Disabled unless PANDORA_ENABLE_MCP_DISTILLATION=true.", inputSchema: { namespace: z.enum(["real_life", "au"]), pack_type: z.enum(["daily", "master"]) } }, async (args) => asContent(await distillContextPackTool(input.client, input.principal, args, input.env))); + server.registerTool("get_latest_context_pack", { title: "Get latest context pack", description: "Return the latest active Pandora context pack for a namespace and optional pack type.", inputSchema: { namespace: z.enum(["real_life", "au"]), pack_type: z.enum(["daily", "master"]).optional() } }, async (args: unknown) => asContent(await getLatestContextPackTool(input.client, input.principal, args))); + server.registerTool("get_memory_context", { title: "Get memory context", description: "Return compact Pandora memory context for a current ChatGPT task.", inputSchema: { namespace: z.enum(["real_life", "au"]), query: z.string().optional(), current_task: z.string().optional(), max_items: z.number().int().positive().optional(), include_risks: z.boolean().optional(), include_people: z.boolean().optional(), include_projects: z.boolean().optional() } }, async (args: unknown) => asContent(await getMemoryContextTool(input.client, input.principal, args))); + server.registerTool("capture_memory_event", { title: "Capture memory event", description: "Capture a reviewable Pandora memory event from ChatGPT/MCP. Disabled unless PANDORA_ENABLE_MCP_CAPTURE=true.", inputSchema: { namespace: z.enum(["real_life", "au"]), raw_text: z.string().min(1).max(8000), source: z.string().optional(), source_ref: z.string().optional(), importance: z.number().int().min(1).max(10).optional(), sensitivity: z.enum(["low", "medium", "high", "private"]).optional() } }, async (args: unknown) => asContent(await captureMemoryEventTool(input.client, input.principal, args, input.env))); + server.registerTool("distill_context_pack", { title: "Distill context pack", description: "Generate deterministic Pandora context pack without model calls or embeddings. Disabled unless PANDORA_ENABLE_MCP_DISTILLATION=true.", inputSchema: { namespace: z.enum(["real_life", "au"]), pack_type: z.enum(["daily", "master"]) } }, async (args: unknown) => asContent(await distillContextPackTool(input.client, input.principal, args, input.env))); + + server.registerTool("get_adaptive_context", { title: "Get adaptive context", description: "Return the best adaptive context for ChatGPT before answering Joven.", inputSchema: { namespace: z.enum(["real_life", "au"]), query: z.string().optional(), current_task: z.string().optional(), max_items: z.number().int().positive().optional() } }, async (args: unknown) => asContent(await getAdaptiveContextTool(input.client, input.principal, args))); + server.registerTool("analyze_memory_candidates", { title: "Analyze memory candidates", description: "Identify what should be saved, reviewed, ignored, or blocked.", inputSchema: { namespace: z.enum(["real_life", "au"]), text: z.string(), source: z.string().optional(), source_ref: z.string().optional(), mode: z.enum(["candidate_only","auto_capture_allowed"]).optional() } }, async (args: unknown) => asContent(await analyzeMemoryCandidatesTool(input.client, input.principal, args))); + server.registerTool("capture_adaptive_memory", { title: "Capture adaptive memory", description: "Capture high-signal durable memory using adaptive policy.", inputSchema: { namespace: z.enum(["real_life", "au"]), text: z.string(), source: z.string().optional(), source_ref: z.string().optional(), mode: z.enum(["candidate_only","auto_capture_allowed"]).optional() } }, async (args: unknown) => asContent(await captureAdaptiveMemoryTool(input.client, input.principal, args))); + server.registerTool("semantic_memory_search", { title: "Semantic memory search", description: "Hybrid retrieval with semantic matches when enabled.", inputSchema: { namespace: z.enum(["real_life", "au"]), query: z.string().optional(), current_task: z.string().optional(), max_items: z.number().int().positive().optional() } }, async (args: unknown) => asContent(await semanticMemorySearchTool(input.client, input.principal, args))); + server.registerTool("create_session_digest", { title: "Create session digest", description: "Digest a session into candidates, events, profiles, and open loops.", inputSchema: { namespace: z.enum(["real_life", "au"]), source: z.string().optional(), source_ref: z.string().optional(), transcript_or_summary: z.string(), auto_capture: z.boolean().optional(), update_profiles: z.boolean().optional(), distill: z.boolean().optional() } }, async (args: unknown) => asContent(await createSessionDigestTool(input.client, input.principal, args))); + server.registerTool("refresh_adaptive_profiles", { title: "Refresh adaptive profiles", description: "Update adaptive profiles from existing memory.", inputSchema: { namespace: z.enum(["real_life", "au"]), profile_types: z.array(z.string()).optional(), subject_keys: z.array(z.string()).optional(), dry_run: z.boolean().optional() } }, async (args: unknown) => asContent(await refreshAdaptiveProfilesTool(input.client, input.principal, args))); + server.registerTool("get_open_loops", { title: "Get open loops", description: "Return unresolved risks, blockers, decisions, and loops.", inputSchema: { namespace: z.enum(["real_life", "au"]) } }, async (args: unknown) => asContent(await getOpenLoopsTool(input.client, input.principal, args))); return server; } diff --git a/lib/services/pandora-mcp-tools.ts b/lib/services/pandora-mcp-tools.ts index abbc122..9f90a40 100644 --- a/lib/services/pandora-mcp-tools.ts +++ b/lib/services/pandora-mcp-tools.ts @@ -5,60 +5,35 @@ import type { MemoryBridgeDbClient, MemoryBridgeNamespace, MemoryContextPack, Me import { createContextPack } from "@/lib/services/memory-bridge-service"; import { buildDailyContextPack, buildMasterContextPack, compactContextResponse } from "@/lib/services/memory-distillation-service"; import { auditPandoraMcpToolCall } from "@/lib/services/pandora-mcp-audit"; +import { buildAdaptiveChatGptContext } from "@/lib/services/adaptive-chatgpt-context-service"; +import { createCandidatesFromSession } from "@/lib/services/memory-candidate-service"; +import { getHybridMemoryContext } from "@/lib/services/memory-hybrid-retrieval-service"; +import { createSessionDigest } from "@/lib/services/memory-session-digest-service"; +import { getOpenLoops } from "@/lib/services/memory-open-loop-service"; +import { upsertProfileFromMemoryEvents } from "@/lib/services/memory-profile-service"; const namespaceSchema = z.enum(["real_life", "au"]); export const latestContextPackInputSchema = z.object({ namespace: namespaceSchema, pack_type: z.enum(["daily", "master"]).optional() }); export const memoryContextInputSchema = z.object({ namespace: namespaceSchema, query: z.string().optional(), current_task: z.string().optional(), max_items: z.number().int().positive().max(100).optional(), include_risks: z.boolean().optional(), include_people: z.boolean().optional(), include_projects: z.boolean().optional() }); export const captureMemoryEventInputSchema = z.object({ namespace: namespaceSchema, raw_text: z.string().trim().min(1).max(8000), source: z.string().trim().max(120).optional(), source_ref: z.string().trim().max(500).optional(), importance: z.number().int().min(1).max(10).optional(), sensitivity: z.enum(["low", "medium", "high", "private"]).optional() }); export const distillContextPackInputSchema = z.object({ namespace: namespaceSchema, pack_type: z.enum(["daily", "master"]) }); - const runtime = (capture = false, distill = false) => ({ config: { memoryCaptureApiEnabled: capture, memoryContextApiEnabled: true, memoryDistillationEnabled: distill }, gates: { memoryCaptureApiEnabled: { envVar: "PANDORA_ENABLE_MCP_CAPTURE" }, memoryContextApiEnabled: { envVar: "PANDORA_ENABLE_MCP" }, memoryDistillationEnabled: { envVar: "PANDORA_ENABLE_MCP_DISTILLATION" } } }) as never; function bridgePrincipal(principal: Extract) { return { ok: true as const, userId: principal.userId, createdBy: principal.userId, authType: "bridge_token" as const, operator: true }; } function eventSummary(event: MemoryEvent) { return event.extracted_summary ?? event.raw_text.replace(/\s+/g, " ").trim().slice(0, 240); } function warning(audit: { ok: boolean; warning?: string }) { return audit.ok ? [] : [audit.warning ?? "mcp_audit_failed"]; } - -export async function getLatestContextPackTool(client: MemoryBridgeDbClient, principal: Extract, rawInput: unknown) { - const input = latestContextPackInputSchema.parse(rawInput); - let query = client.from("memory_context_packs").select("id,namespace,pack_type,title,summary,key_points,active_projects,people_map,decisions,risks,open_loops,created_at").eq("user_id", principal.userId).eq("namespace", input.namespace).eq("status", "active").order("created_at", { ascending: false }).limit(1); - if (input.pack_type) query = query.eq("pack_type", input.pack_type); - const result = await (query as unknown as Promise<{ data: MemoryContextPack[] | null; error: { message: string } | null }>); - if (result.error) throw new Error(`context_pack_read_failed: ${result.error.message}`); - const audit = await auditPandoraMcpToolCall(client, { principal, tool: "mcp.get_latest_context_pack", namespace: input.namespace }); - return { context_pack: result.data?.[0] ?? null, warnings: warning(audit) }; -} - -export async function getMemoryContextTool(client: MemoryBridgeDbClient, principal: Extract, rawInput: unknown) { - const input = memoryContextInputSchema.parse(rawInput); - const maxItems = Math.min(Math.max(input.max_items ?? 8, 1), 20); - const packs = await (client.from("memory_context_packs").select("*").eq("user_id", principal.userId).eq("namespace", input.namespace).eq("status", "active").order("created_at", { ascending: false }).limit(1) as unknown as Promise<{ data: MemoryContextPack[] | null; error: { message: string } | null }>); - const events = await (client.from("memory_events").select("*").eq("user_id", principal.userId).eq("namespace", input.namespace).neq("status", "archived").order("created_at", { ascending: false }).limit(maxItems) as unknown as Promise<{ data: MemoryEvent[] | null; error: { message: string } | null }>); - if (packs.error || events.error) throw new Error(`context_read_failed: ${packs.error?.message ?? events.error?.message}`); - const pack = compactContextResponse(packs.data?.[0] ?? null, events.data ?? [], input); - const audit = await auditPandoraMcpToolCall(client, { principal, tool: "mcp.get_memory_context", namespace: input.namespace }); - return { namespace: input.namespace, current_task: input.current_task ?? null, context_pack: pack, recent_events: (events.data ?? []).map((event) => ({ id: event.id, source: event.source, summary: eventSummary(event), status: event.status, created_at: event.created_at })).slice(0, maxItems), warnings: [...(packs.data?.[0] ? [] : ["no_active_context_pack_yet"]), ...warning(audit)], open_loops: pack.open_loops }; -} - -export async function captureMemoryEventTool(client: MemoryBridgeDbClient, principal: Extract, rawInput: unknown, env: Partial = process.env) { - const gate = requireMcpCaptureEnabled(env); if (!gate.ok) return gate; - const input = captureMemoryEventInputSchema.parse(rawInput); - const row = { namespace: input.namespace, user_id: principal.userId, source: input.source || "chatgpt_mcp", source_ref: input.source_ref, raw_text: input.raw_text, extracted_summary: input.raw_text.replace(/\s+/g, " ").trim().slice(0, 240), importance: input.importance ?? 5, sensitivity: input.sensitivity ?? "medium", status: "captured", created_by: principal.userId }; - const result = await client.from("memory_events").insert(row).select("*").single(); - if (result.error || !result.data) throw new Error(`capture_write_failed: ${result.error?.message ?? "unknown write failure"}`); - const audit = await auditPandoraMcpToolCall(client, { principal, tool: "mcp.capture_memory_event", namespace: input.namespace, recordId: result.data.id }); - return { id: result.data.id, status: result.data.status, namespace: result.data.namespace, source: result.data.source, created_at: result.data.created_at, warnings: warning(audit) }; -} - -export async function distillContextPackTool(client: MemoryBridgeDbClient, principal: Extract, rawInput: unknown, env: Partial = process.env) { - const gate = requireMcpDistillationEnabled(env); if (!gate.ok) return gate; - const input = distillContextPackInputSchema.parse(rawInput); - const events = await (client.from("memory_events").select("*").eq("user_id", principal.userId).eq("namespace", input.namespace).neq("status", "archived").order("created_at", { ascending: false }).limit(input.pack_type === "master" ? 50 : 25) as unknown as Promise<{ data: MemoryEvent[] | null; error: { message: string } | null }>); - if (events.error) throw new Error(`event_read_failed: ${events.error.message}`); - const pack = input.pack_type === "master" ? buildMasterContextPack(input.namespace, principal.userId, events.data ?? []) : buildDailyContextPack(input.namespace, principal.userId, events.data ?? []); - const result = await createContextPack(client, pack, bridgePrincipal(principal), runtime(false, true)); - if (!result.ok) throw new Error(result.blockers.join(",")); - const audit = await auditPandoraMcpToolCall(client, { principal, tool: "mcp.distill_context_pack", namespace: input.namespace, recordId: result.data.id }); - return { id: result.data.id, namespace: result.data.namespace, pack_type: result.data.pack_type, title: result.data.title, summary: result.data.summary, created_at: result.data.created_at, status: result.data.status, warnings: warning(audit) }; -} - +export async function getLatestContextPackTool(client: MemoryBridgeDbClient, principal: Extract, rawInput: unknown) { const input = latestContextPackInputSchema.parse(rawInput); let query = client.from("memory_context_packs").select("id,namespace,pack_type,title,summary,key_points,active_projects,people_map,decisions,risks,open_loops,created_at").eq("user_id", principal.userId).eq("namespace", input.namespace).eq("status", "active").order("created_at", { ascending: false }).limit(1); if (input.pack_type) query = query.eq("pack_type", input.pack_type); const result = await (query as unknown as Promise<{ data: MemoryContextPack[] | null; error: { message: string } | null }>); if (result.error) throw new Error(`context_pack_read_failed: ${result.error.message}`); const audit = await auditPandoraMcpToolCall(client, { principal, tool: "mcp.get_latest_context_pack", namespace: input.namespace }); return { context_pack: result.data?.[0] ?? null, warnings: warning(audit) }; } +export async function getMemoryContextTool(client: MemoryBridgeDbClient, principal: Extract, rawInput: unknown) { const input = memoryContextInputSchema.parse(rawInput); const maxItems = Math.min(Math.max(input.max_items ?? 8, 1), 20); const packs = await (client.from("memory_context_packs").select("*").eq("user_id", principal.userId).eq("namespace", input.namespace).eq("status", "active").order("created_at", { ascending: false }).limit(1) as unknown as Promise<{ data: MemoryContextPack[] | null; error: { message: string } | null }>); const events = await (client.from("memory_events").select("*").eq("user_id", principal.userId).eq("namespace", input.namespace).neq("status", "archived").order("created_at", { ascending: false }).limit(maxItems) as unknown as Promise<{ data: MemoryEvent[] | null; error: { message: string } | null }>); if (packs.error || events.error) throw new Error(`context_read_failed: ${packs.error?.message ?? events.error?.message}`); const pack = compactContextResponse(packs.data?.[0] ?? null, events.data ?? [], input); const audit = await auditPandoraMcpToolCall(client, { principal, tool: "mcp.get_memory_context", namespace: input.namespace }); return { namespace: input.namespace, current_task: input.current_task ?? null, context_pack: pack, recent_events: (events.data ?? []).map((event) => ({ id: event.id, source: event.source, summary: eventSummary(event), status: event.status, created_at: event.created_at })).slice(0, maxItems), warnings: [...(packs.data?.[0] ? [] : ["no_active_context_pack_yet"]), ...warning(audit)], open_loops: pack.open_loops }; } +export async function captureMemoryEventTool(client: MemoryBridgeDbClient, principal: Extract, rawInput: unknown, env: Partial = process.env) { const gate = requireMcpCaptureEnabled(env); if (!gate.ok) return gate; const input = captureMemoryEventInputSchema.parse(rawInput); const row = { namespace: input.namespace, user_id: principal.userId, source: input.source || "chatgpt_mcp", source_ref: input.source_ref, raw_text: input.raw_text, extracted_summary: input.raw_text.replace(/\s+/g, " ").trim().slice(0, 240), importance: input.importance ?? 5, sensitivity: input.sensitivity ?? "medium", status: "captured", created_by: principal.userId }; const result = await client.from("memory_events").insert(row).select("*").single(); if (result.error || !result.data) throw new Error(`capture_write_failed: ${result.error?.message ?? "unknown write failure"}`); const audit = await auditPandoraMcpToolCall(client, { principal, tool: "mcp.capture_memory_event", namespace: input.namespace, recordId: result.data.id }); return { id: result.data.id, status: result.data.status, namespace: result.data.namespace, source: result.data.source, created_at: result.data.created_at, warnings: warning(audit) }; } +export async function distillContextPackTool(client: MemoryBridgeDbClient, principal: Extract, rawInput: unknown, env: Partial = process.env) { const gate = requireMcpDistillationEnabled(env); if (!gate.ok) return gate; const input = distillContextPackInputSchema.parse(rawInput); const events = await (client.from("memory_events").select("*").eq("user_id", principal.userId).eq("namespace", input.namespace).neq("status", "archived").order("created_at", { ascending: false }).limit(input.pack_type === "master" ? 50 : 25) as unknown as Promise<{ data: MemoryEvent[] | null; error: { message: string } | null }>); if (events.error) throw new Error(`event_read_failed: ${events.error.message}`); const pack = input.pack_type === "master" ? buildMasterContextPack(input.namespace, principal.userId, events.data ?? []) : buildDailyContextPack(input.namespace, principal.userId, events.data ?? []); const result = await createContextPack(client, pack, bridgePrincipal(principal), runtime(false, true)); if (!result.ok) throw new Error(result.blockers.join(",")); const audit = await auditPandoraMcpToolCall(client, { principal, tool: "mcp.distill_context_pack", namespace: input.namespace, recordId: result.data.id }); return { id: result.data.id, namespace: result.data.namespace, pack_type: result.data.pack_type, title: result.data.title, summary: result.data.summary, created_at: result.data.created_at, status: result.data.status, warnings: warning(audit) }; } export function capMcpMaxItems(value?: number) { return Math.min(Math.max(value ?? 8, 1), 20); } export type PandoraMcpNamespace = MemoryBridgeNamespace; +export const adaptiveContextInputSchema = memoryContextInputSchema; +export const analyzeMemoryCandidatesInputSchema = z.object({ namespace: namespaceSchema, text: z.string().min(1).max(20000), source: z.string().optional(), source_ref: z.string().optional(), mode: z.enum(["candidate_only","auto_capture_allowed"]).optional() }); +export const sessionDigestInputSchema = z.object({ namespace: namespaceSchema, source: z.string().optional(), source_ref: z.string().optional(), transcript_or_summary: z.string().min(1).max(40000), auto_capture: z.boolean().optional(), update_profiles: z.boolean().optional(), distill: z.boolean().optional() }); +export async function getAdaptiveContextTool(client: MemoryBridgeDbClient, principal: Extract, rawInput: unknown) { const input=adaptiveContextInputSchema.parse(rawInput); const data=await buildAdaptiveChatGptContext(client,{user_id:principal.userId,namespace:input.namespace,query:input.query,current_task:input.current_task,max_items:input.max_items}); const audit=await auditPandoraMcpToolCall(client,{principal,tool:"mcp.get_adaptive_context",namespace:input.namespace}); return {...data,warnings:[...(data.warnings??[]),...warning(audit)]}; } +export async function analyzeMemoryCandidatesTool(client: MemoryBridgeDbClient, principal: Extract, rawInput: unknown, env: Partial = process.env) { const gate=requireMcpCaptureEnabled(env); if(!gate.ok)return gate; const input=analyzeMemoryCandidatesInputSchema.parse(rawInput); const data=await createCandidatesFromSession(client,{user_id:principal.userId,namespace:input.namespace,source:input.source??"mcp_adaptive_analyze",source_ref:input.source_ref,text:input.text,mode:input.mode}); const audit=await auditPandoraMcpToolCall(client,{principal,tool:"mcp.analyze_memory_candidates",namespace:input.namespace}); return {...data,warnings:[...(data.warnings??[]),...warning(audit)]}; } +export async function semanticMemorySearchTool(client: MemoryBridgeDbClient, principal: Extract, rawInput: unknown) { const input=memoryContextInputSchema.parse(rawInput); const data=await getHybridMemoryContext(client,{user_id:principal.userId,namespace:input.namespace,query:input.query,current_task:input.current_task,max_items:input.max_items}); const audit=await auditPandoraMcpToolCall(client,{principal,tool:"mcp.semantic_memory_search",namespace:input.namespace}); return {...data,warnings:[...data.warnings,...warning(audit)]}; } +export async function createSessionDigestTool(client: MemoryBridgeDbClient, principal: Extract, rawInput: unknown, env: Partial = process.env) { const captureGate=requireMcpCaptureEnabled(env); if(!captureGate.ok)return captureGate; const distillGate=requireMcpDistillationEnabled(env); if(!distillGate.ok)return distillGate; const input=sessionDigestInputSchema.parse(rawInput); const data=await createSessionDigest(client,{user_id:principal.userId,namespace:input.namespace,source:input.source??"mcp_session",source_ref:input.source_ref,transcript_or_summary:input.transcript_or_summary,auto_capture:input.auto_capture,update_profiles:input.update_profiles,distill:input.distill}); const audit=await auditPandoraMcpToolCall(client,{principal,tool:"mcp.create_session_digest",namespace:input.namespace}); return {...data,warnings:[...(data.warnings??[]),...warning(audit)]}; } +export async function refreshAdaptiveProfilesTool(client: MemoryBridgeDbClient, principal: Extract, rawInput: unknown, env: Partial = process.env) { const input=z.object({namespace:namespaceSchema,profile_types:z.array(z.string()).optional(),subject_keys:z.array(z.string()).optional(),dry_run:z.boolean().optional()}).parse(rawInput); if(!input.dry_run){ const captureGate=requireMcpCaptureEnabled(env); if(!captureGate.ok)return captureGate; const distillGate=requireMcpDistillationEnabled(env); if(!distillGate.ok)return distillGate; } const result=await upsertProfileFromMemoryEvents(client,{user_id:principal.userId,namespace:input.namespace,profile_type:input.profile_types?.[0]??"operating_profile",subject_key:input.subject_keys?.[0]??"global",summary:"MCP adaptive profile refresh requested.",evidence_refs:[],dry_run:input.dry_run}); const audit=await auditPandoraMcpToolCall(client,{principal,tool:"mcp.refresh_adaptive_profiles",namespace:input.namespace}); return {result,warnings:warning(audit)}; } +export async function getOpenLoopsTool(client: MemoryBridgeDbClient, principal: Extract, rawInput: unknown) { const input=z.object({namespace:namespaceSchema}).parse(rawInput); const loops=await getOpenLoops(client,principal.userId,input.namespace); const audit=await auditPandoraMcpToolCall(client,{principal,tool:"mcp.get_open_loops",namespace:input.namespace}); return {open_loops:loops.data??[],warnings:[...(loops.error?[loops.error.message]:[]),...warning(audit)]}; } +export async function captureAdaptiveMemoryTool(client: MemoryBridgeDbClient, principal: Extract, rawInput: unknown, env: Partial = process.env) { return analyzeMemoryCandidatesTool(client,principal,rawInput,env); } diff --git a/lib/services/providers/openai-memory-embedding-provider.ts b/lib/services/providers/openai-memory-embedding-provider.ts new file mode 100644 index 0000000..b904eaf --- /dev/null +++ b/lib/services/providers/openai-memory-embedding-provider.ts @@ -0,0 +1 @@ +export { createMemoryEmbedding } from "@/lib/services/memory-embedding-service"; diff --git a/lib/services/providers/openai-memory-model-provider.ts b/lib/services/providers/openai-memory-model-provider.ts new file mode 100644 index 0000000..8cfd47d --- /dev/null +++ b/lib/services/providers/openai-memory-model-provider.ts @@ -0,0 +1 @@ +export { classifyMemoryCandidatesWithProvider as classifyMemoryCandidates, summarizeSession, extractAdaptiveProfile, distillContextPack } from "@/lib/services/memory-model-provider"; diff --git a/public/pandora-memory-openapi.json b/public/pandora-memory-openapi.json index c11fbff..c300c6e 100644 --- a/public/pandora-memory-openapi.json +++ b/public/pandora-memory-openapi.json @@ -14,18 +14,28 @@ "schemas": { "Namespace": { "type": "string", - "enum": ["real_life", "au"], + "enum": [ + "real_life", + "au" + ], "description": "Memory namespace to query or write into." }, "Sensitivity": { "type": "string", - "enum": ["low", "medium", "high", "private"], + "enum": [ + "low", + "medium", + "high", + "private" + ], "description": "Review sensitivity label for a captured memory event." }, "ContextRequest": { "type": "object", "additionalProperties": false, - "required": ["namespace"], + "required": [ + "namespace" + ], "properties": { "namespace": { "$ref": "#/components/schemas/Namespace" @@ -83,7 +93,10 @@ "CaptureRequest": { "type": "object", "additionalProperties": false, - "required": ["namespace", "raw_text"], + "required": [ + "namespace", + "raw_text" + ], "properties": { "namespace": { "$ref": "#/components/schemas/Namespace" @@ -128,14 +141,20 @@ "DistillRequest": { "type": "object", "additionalProperties": false, - "required": ["namespace", "pack_type"], + "required": [ + "namespace", + "pack_type" + ], "properties": { "namespace": { "$ref": "#/components/schemas/Namespace" }, "pack_type": { "type": "string", - "enum": ["daily", "master"], + "enum": [ + "daily", + "master" + ], "default": "master" } } @@ -169,6 +188,10 @@ "type": "string" } } + }, + "AdaptiveMemoryResponse": { + "type": "object", + "additionalProperties": true } }, "securitySchemes": { @@ -343,6 +366,181 @@ } } } + }, + "/api/memory/adaptive/context": { + "post": { + "operationId": "api_memory_adaptive_context", + "summary": "Return adaptive ChatGPT context", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + } + }, + "/api/memory/adaptive/analyze": { + "post": { + "operationId": "api_memory_adaptive_analyze", + "summary": "Analyze memory candidates", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + } + }, + "/api/memory/adaptive/session-digest": { + "post": { + "operationId": "api_memory_adaptive_session_digest", + "summary": "Create session digest", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + } + }, + "/api/memory/search": { + "post": { + "operationId": "api_memory_search", + "summary": "Hybrid memory search", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + } + }, + "/api/memory/profiles/refresh": { + "post": { + "operationId": "api_memory_profiles_refresh", + "summary": "Refresh adaptive profiles", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + } } } -} +} \ No newline at end of file diff --git a/supabase/migrations/20260627040000_phase_4c_adaptive_memory_intelligence.sql b/supabase/migrations/20260627040000_phase_4c_adaptive_memory_intelligence.sql new file mode 100644 index 0000000..9faabfc --- /dev/null +++ b/supabase/migrations/20260627040000_phase_4c_adaptive_memory_intelligence.sql @@ -0,0 +1,24 @@ +create extension if not exists vector; +create table if not exists public.memory_capture_candidates (id uuid primary key default gen_random_uuid(), user_id uuid not null, namespace text not null check(namespace in ('real_life','au')), source text not null, source_ref text, raw_excerpt text, redacted_excerpt text, memory_type text, title text, summary text, importance integer check(importance between 1 and 10), sensitivity text, confidence numeric, should_capture boolean, requires_review boolean, status text default 'pending' check(status in ('pending','approved','rejected','captured','blocked_secret','duplicate')), reason text, people jsonb default '[]', projects jsonb default '[]', risks jsonb default '[]', tags jsonb default '[]', metadata jsonb default '{}', created_at timestamptz default now(), reviewed_at timestamptz, captured_event_id uuid); +create table if not exists public.memory_profiles (id uuid primary key default gen_random_uuid(), user_id uuid not null, namespace text not null check(namespace in ('real_life','au')), profile_type text not null, subject_key text not null, title text, summary text, facts jsonb default '[]', preferences jsonb default '[]', patterns jsonb default '[]', risks jsonb default '[]', open_loops jsonb default '[]', decisions jsonb default '[]', evidence_refs jsonb default '[]', confidence numeric, status text default 'active', version integer default 1, created_at timestamptz default now(), updated_at timestamptz default now()); +create unique index if not exists memory_profiles_active_unique on public.memory_profiles(user_id, namespace, profile_type, subject_key, status) where status='active'; +create table if not exists public.memory_embeddings (id uuid primary key default gen_random_uuid(), user_id uuid not null, namespace text not null check(namespace in ('real_life','au')), source_table text not null, source_id uuid not null, memory_type text, content_hash text not null, embedding vector(1536), embedding_model text not null, embedding_dimensions integer not null default 1536, importance integer, sensitivity text, metadata jsonb default '{}', created_at timestamptz default now(), updated_at timestamptz default now()); +create table if not exists public.memory_session_digests (id uuid primary key default gen_random_uuid(), user_id uuid not null, namespace text not null check(namespace in ('real_life','au')), source text not null, source_ref text, title text, summary text, durable_updates jsonb default '[]', decisions jsonb default '[]', open_loops jsonb default '[]', risks jsonb default '[]', people jsonb default '[]', projects jsonb default '[]', style_updates jsonb default '[]', candidate_ids jsonb default '[]', captured_event_ids jsonb default '[]', profile_ids jsonb default '[]', created_at timestamptz default now()); +create table if not exists public.memory_open_loops (id uuid primary key default gen_random_uuid(), user_id uuid not null, namespace text not null check(namespace in ('real_life','au')), loop_type text not null, subject_key text not null, title text, description text, severity integer, status text default 'open' check(status in ('open','acknowledged','resolved','deferred')), evidence_refs jsonb default '[]', next_action text, created_at timestamptz default now(), updated_at timestamptz default now(), resolved_at timestamptz); +create table if not exists public.memory_retrieval_logs (id uuid primary key default gen_random_uuid(), user_id uuid not null, namespace text, query_hash text, metadata jsonb default '{}', created_at timestamptz default now()); +create table if not exists public.memory_model_call_logs (id uuid primary key default gen_random_uuid(), user_id uuid, namespace text, provider text, model text, task text, input_char_count integer, redacted boolean default true, metadata jsonb default '{}', created_at timestamptz default now()); +create index if not exists memory_capture_candidates_user_ns_idx on public.memory_capture_candidates(user_id, namespace, created_at desc); +create index if not exists memory_profiles_user_ns_idx on public.memory_profiles(user_id, namespace, profile_type); +create index if not exists memory_embeddings_user_ns_idx on public.memory_embeddings(user_id, namespace); +create index if not exists memory_embeddings_source_idx on public.memory_embeddings(source_table, source_id); +create index if not exists memory_embeddings_vector_idx on public.memory_embeddings using ivfflat (embedding vector_cosine_ops) with (lists = 100); +create index if not exists memory_session_digests_user_ns_idx on public.memory_session_digests(user_id, namespace, created_at desc); +create index if not exists memory_open_loops_user_ns_idx on public.memory_open_loops(user_id, namespace, status); +alter table public.memory_capture_candidates enable row level security; alter table public.memory_profiles enable row level security; alter table public.memory_embeddings enable row level security; alter table public.memory_session_digests enable row level security; alter table public.memory_open_loops enable row level security; alter table public.memory_retrieval_logs enable row level security; alter table public.memory_model_call_logs enable row level security; +do $$ begin + create policy "memory_capture_candidates_user_scoped" on public.memory_capture_candidates for all using (auth.uid() = user_id) with check (auth.uid() = user_id); + create policy "memory_profiles_user_scoped" on public.memory_profiles for all using (auth.uid() = user_id) with check (auth.uid() = user_id); + create policy "memory_embeddings_user_scoped" on public.memory_embeddings for all using (auth.uid() = user_id) with check (auth.uid() = user_id); + create policy "memory_session_digests_user_scoped" on public.memory_session_digests for all using (auth.uid() = user_id) with check (auth.uid() = user_id); + create policy "memory_open_loops_user_scoped" on public.memory_open_loops for all using (auth.uid() = user_id) with check (auth.uid() = user_id); +exception when duplicate_object then null; end $$; diff --git a/tests/phase4c-adaptive.test.ts b/tests/phase4c-adaptive.test.ts new file mode 100644 index 0000000..9d0d5f3 --- /dev/null +++ b/tests/phase4c-adaptive.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { detectSecrets, redactSecrets } from "../lib/services/memory-redaction-service"; +import { classifyMemoryCandidatesDeterministic } from "../lib/services/memory-classification-service"; +import { createCandidatesFromSession } from "../lib/services/memory-candidate-service"; +import { createMemoryEmbedding } from "../lib/services/memory-embedding-service"; +import { analyzeMemoryCandidatesTool, createSessionDigestTool, refreshAdaptiveProfilesTool } from "../lib/services/pandora-mcp-tools"; + +describe("Phase 4C secret detection", () => { + it("detects bearer tokens and OpenAI-like keys", () => { expect(detectSecrets("Bearer abcdefghijklmnopqrstuvwxyz123456").detected).toBe(true); expect(detectSecrets("sk-abcdefghijklmnopqrstuvwxyz123456").detected).toBe(true); }); + it("redacts before model/embedding", () => { expect(redactSecrets("token=abcdefghijklmnopqrstuvwxyz1234567890")).toContain("[REDACTED_SECRET]"); }); +}); +describe("Phase 4C classification", () => { + it("detects operating preference", () => { expect(classifyMemoryCandidatesDeterministic({text:"I prefer blunt concise answers"})[0].memory_type).toBe("operating_preference"); }); + it("detects project decision/status", () => { expect(classifyMemoryCandidatesDeterministic({text:"We decided to deploy Pandora on Vercel"})[0].should_capture).toBe(true); }); + it("detects gambling risk", () => { expect(classifyMemoryCandidatesDeterministic({text:"Gambling risk came up again"})[0].memory_type).toBe("gambling_risk"); }); + it("detects AU canon", () => { expect(classifyMemoryCandidatesDeterministic({namespace:"au", text:"This AU canon says Melodee knows the rule"})[0].namespace).toBe("au"); }); + it("ignores low signal noise", () => { expect(classifyMemoryCandidatesDeterministic({text:"what is 2+2"})[0].should_capture).toBe(false); }); + it("blocks secrets", () => { const c=classifyMemoryCandidatesDeterministic({text:"OPENAI_API_KEY=sk-abcdefghijklmnopqrstuvwxyz123456"})[0]; expect(c.memory_type).toBe("secret_or_credential"); expect(c.should_capture).toBe(false); }); +}); +describe("Phase 4C candidate safety", () => { + it("forces blocked status for credential-like input even when model calls are enabled", async()=>{ let inserted: unknown[] | null = null; const client={from:()=>({insert:(rows:unknown[])=>{inserted=rows; return {select:()=>Promise.resolve({data:rows,error:null})};}})} as never; const result=await createCandidatesFromSession(client,{user_id:"00000000-0000-0000-0000-000000000000",namespace:"real_life",source:"test",text:"OPENAI_API_KEY=sk-abcdefghijklmnopqrstuvwxyz123456"},{PANDORA_ENABLE_MODEL_CALLS:"true"}); expect(result.candidates[0]).toMatchObject({status:"blocked_secret",memory_type:"secret_or_credential",should_capture:false}); expect(inserted).toBeTruthy(); }); +}); +describe("Phase 4C MCP gates", () => { + const principal={ok:true as const,authType:"mcp_bearer_token" as const,userId:"00000000-0000-0000-0000-000000000000"}; + const client={from:()=>{throw new Error("unexpected write");}} as never; + it("blocks adaptive candidate writes when MCP capture is disabled", async()=>{ const result=await analyzeMemoryCandidatesTool(client,principal,{namespace:"real_life",text:"we decided to ship"},{PANDORA_ENABLE_MCP_CAPTURE:"false"}); expect(result).toMatchObject({ok:false,code:"mcp_capture_disabled"}); }); + it("blocks session digest writes when MCP capture is disabled", async()=>{ const result=await createSessionDigestTool(client,principal,{namespace:"real_life",transcript_or_summary:"summary"},{PANDORA_ENABLE_MCP_CAPTURE:"false",PANDORA_ENABLE_MCP_DISTILLATION:"true"}); expect(result).toMatchObject({ok:false,code:"mcp_capture_disabled"}); }); + it("blocks profile refresh writes when MCP capture is disabled", async()=>{ const result=await refreshAdaptiveProfilesTool(client,principal,{namespace:"real_life"},{PANDORA_ENABLE_MCP_CAPTURE:"false",PANDORA_ENABLE_MCP_DISTILLATION:"true"}); expect(result).toMatchObject({ok:false,code:"mcp_capture_disabled"}); }); +}); +describe("Phase 4C REST gates", () => { + it("blocks adaptive search when context API gate is disabled", async()=>{ process.env.PANDORA_ENABLE_MEMORY_CONTEXT_API="false"; const { POST } = await import("../app/api/memory/search/route"); const response=await POST(new Request("http://localhost/api/memory/search",{method:"POST",body:JSON.stringify({namespace:"real_life"})}) as never); expect(response.status).toBe(403); expect((await response.json()).blockers).toContain("memoryContextApiEnabled_disabled"); }); + it("blocks adaptive analyze when capture API gate is disabled", async()=>{ process.env.PANDORA_ENABLE_MEMORY_CAPTURE_API="false"; const { POST } = await import("../app/api/memory/adaptive/analyze/route"); const response=await POST(new Request("http://localhost/api/memory/adaptive/analyze",{method:"POST",body:JSON.stringify({namespace:"real_life",text:"decision"})}) as never); expect(response.status).toBe(403); expect((await response.json()).blockers).toContain("memoryCaptureApiEnabled_disabled"); }); +}); +describe("Phase 4C embeddings", () => { it("no embedding when disabled", async()=>{ const r=await createMemoryEmbedding({text:"hello",user_id:"u",namespace:"real_life",source_table:"memory_events",source_id:"00000000-0000-0000-0000-000000000000"},{PANDORA_ENABLE_EMBEDDINGS:"false"}); expect(r.enabled).toBe(false); }); });