This commit is contained in:
@@ -1,11 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { AppShell } from "@/components/layout/app-shell";
|
import { AppShell } from "@/components/layout/app-shell";
|
||||||
import { MentorChatMessages } from "@/components/features/mentor-chat-messages";
|
import { MentorChatMessages } from "@/components/features/mentor-chat-messages";
|
||||||
import { createChatSession, fetchChatSessions, useMentorChat } from "@/hooks/useMentorChat";
|
import { createChatSession, fetchChatSessions, useMentorChat } from "@/hooks/useMentorChat";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { MEMORY_CATEGORIES, type MemoryCategory } from "@adventureos/shared";
|
||||||
|
|
||||||
const SUGGESTED = [
|
const SUGGESTED = [
|
||||||
"What should I focus on today?",
|
"What should I focus on today?",
|
||||||
@@ -16,7 +17,17 @@ const SUGGESTED = [
|
|||||||
"What goals am I working towards?",
|
"What goals am I working towards?",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type MemorySuggestion = {
|
||||||
|
id: string;
|
||||||
|
category: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
sourceType: string;
|
||||||
|
confidence: string;
|
||||||
|
};
|
||||||
|
|
||||||
export default function MentorPage() {
|
export default function MentorPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||||
const [tab, setTab] = useState<"chat" | "knows" | "preview">("chat");
|
const [tab, setTab] = useState<"chat" | "knows" | "preview">("chat");
|
||||||
const [previewInput, setPreviewInput] = useState("");
|
const [previewInput, setPreviewInput] = useState("");
|
||||||
@@ -28,27 +39,46 @@ export default function MentorPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionId && sessionsData?.sessions?.[0]) {
|
if (!sessionId && sessionsData?.sessions?.[0]) {
|
||||||
setSessionId(sessionsData.sessions[0].id);
|
queueMicrotask(() => setSessionId(sessionsData.sessions[0].id));
|
||||||
} else if (!sessionId) {
|
} else if (!sessionId) {
|
||||||
createChatSession().then((d) => setSessionId(d.id));
|
createChatSession().then((d) => setSessionId(d.id));
|
||||||
}
|
}
|
||||||
}, [sessionId, sessionsData]);
|
}, [sessionId, sessionsData]);
|
||||||
|
|
||||||
const chat = useMentorChat(sessionId);
|
const chat = useMentorChat(sessionId);
|
||||||
|
const { archiveSuccess, resetArchiveSuccess } = chat;
|
||||||
|
|
||||||
|
const { data: suggestionsData } = useQuery<{ suggestions: MemorySuggestion[] }>({
|
||||||
|
queryKey: ["ai-memory-suggestions"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch("/api/ai/memory/suggestions");
|
||||||
|
if (!res.ok) throw new Error("Failed to load memory suggestions");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
enabled: tab === "chat",
|
||||||
|
});
|
||||||
|
|
||||||
|
const pendingSuggestions = suggestionsData?.suggestions ?? [];
|
||||||
|
|
||||||
|
const refreshMemory = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["ai-memory-suggestions"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["ai-knows"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["context-preview"] });
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!chat.archiveSuccess || !sessionId) return;
|
if (!archiveSuccess || !sessionId) return;
|
||||||
chat.resetArchiveSuccess();
|
resetArchiveSuccess();
|
||||||
|
|
||||||
const remaining = (sessionsData?.sessions ?? []).filter(
|
const remaining = (sessionsData?.sessions ?? []).filter(
|
||||||
(s: { id: string }) => s.id !== sessionId
|
(s: { id: string }) => s.id !== sessionId
|
||||||
);
|
);
|
||||||
if (remaining.length > 0) {
|
if (remaining.length > 0) {
|
||||||
setSessionId(remaining[0].id);
|
queueMicrotask(() => setSessionId(remaining[0].id));
|
||||||
} else {
|
} else {
|
||||||
createChatSession().then((d) => setSessionId(d.id));
|
createChatSession().then((d) => setSessionId(d.id));
|
||||||
}
|
}
|
||||||
}, [chat.archiveSuccess, sessionId, sessionsData, chat.resetArchiveSuccess]);
|
}, [archiveSuccess, sessionId, sessionsData, resetArchiveSuccess]);
|
||||||
|
|
||||||
const { data: knowsData } = useQuery({
|
const { data: knowsData } = useQuery({
|
||||||
queryKey: ["ai-knows"],
|
queryKey: ["ai-knows"],
|
||||||
@@ -139,6 +169,25 @@ export default function MentorPage() {
|
|||||||
bottomRef={chat.bottomRef}
|
bottomRef={chat.bottomRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{pendingSuggestions.length > 0 && (
|
||||||
|
<div className="border-t px-3 py-2 space-y-2 bg-[var(--xp-blue)]/5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-xs font-bold">
|
||||||
|
{pendingSuggestions.length} memory suggestion{pendingSuggestions.length === 1 ? "" : "s"}
|
||||||
|
</p>
|
||||||
|
<Link href="/settings?section=ai-memory" className="text-[10px] underline text-[var(--warm-grey)]">
|
||||||
|
Review all
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{pendingSuggestions.slice(0, 2).map((suggestion) => (
|
||||||
|
<ChatMemorySuggestionCard
|
||||||
|
key={suggestion.id}
|
||||||
|
suggestion={suggestion}
|
||||||
|
onChange={refreshMemory}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="p-2 flex flex-wrap gap-1 border-t">
|
<div className="p-2 flex flex-wrap gap-1 border-t">
|
||||||
{SUGGESTED.map((q) => (
|
{SUGGESTED.map((q) => (
|
||||||
<button
|
<button
|
||||||
@@ -221,3 +270,92 @@ export default function MentorPage() {
|
|||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ChatMemorySuggestionCard({
|
||||||
|
suggestion,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
suggestion: MemorySuggestion;
|
||||||
|
onChange: () => void;
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [title, setTitle] = useState(suggestion.title);
|
||||||
|
const [content, setContent] = useState(suggestion.content);
|
||||||
|
const [category, setCategory] = useState(suggestion.category);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
const accept = async () => {
|
||||||
|
setPending(true);
|
||||||
|
await fetch(`/api/ai/memory/suggestions/${suggestion.id}/accept`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(editing ? { title, content, category } : {}),
|
||||||
|
});
|
||||||
|
setPending(false);
|
||||||
|
onChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
const reject = async () => {
|
||||||
|
setPending(true);
|
||||||
|
await fetch(`/api/ai/memory/suggestions/${suggestion.id}/reject`, { method: "POST" });
|
||||||
|
setPending(false);
|
||||||
|
onChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="retro-window p-2 text-xs border-l-4 border-[var(--xp-blue)]">
|
||||||
|
{editing ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
className="retro-window-inset w-full p-1"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="retro-window-inset w-full p-1"
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
>
|
||||||
|
{Object.entries(MEMORY_CATEGORIES).map(([key, label]) => (
|
||||||
|
<option key={key} value={key}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<textarea
|
||||||
|
className="retro-window-inset w-full p-1 min-h-16"
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="font-bold">{suggestion.title}</p>
|
||||||
|
<p className="mt-1">{suggestion.content}</p>
|
||||||
|
<p className="text-[10px] text-[var(--warm-grey)] mt-1">
|
||||||
|
{MEMORY_CATEGORIES[suggestion.category as MemoryCategory] ?? suggestion.category} · Source: AI chat
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="retro-btn retro-btn-primary text-[10px]"
|
||||||
|
onClick={accept}
|
||||||
|
disabled={pending || !title.trim() || !content.trim()}
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="retro-btn text-[10px]"
|
||||||
|
onClick={() => setEditing((value) => !value)}
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
{editing ? "Cancel edit" : "Edit"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="retro-btn text-[10px]" onClick={reject} disabled={pending}>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -321,24 +321,67 @@ function MemoryRow({ memory, onChange }: { memory: Memory; onChange: () => void
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SuggestionRow({ suggestion, onChange }: { suggestion: Suggestion; onChange: () => void }) {
|
function SuggestionRow({ suggestion, onChange }: { suggestion: Suggestion; onChange: () => void }) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [title, setTitle] = useState(suggestion.title);
|
||||||
|
const [content, setContent] = useState(suggestion.content);
|
||||||
|
const [category, setCategory] = useState(suggestion.category);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="retro-window p-2 text-sm border-l-4 border-[var(--xp-blue)]">
|
<div className="retro-window p-2 text-sm border-l-4 border-[var(--xp-blue)]">
|
||||||
<p className="font-bold">{suggestion.title}</p>
|
{editing ? (
|
||||||
<p className="text-xs mt-1">{suggestion.content}</p>
|
<div className="space-y-2">
|
||||||
<p className="text-[10px] text-[var(--warm-grey)]">
|
<input
|
||||||
{MEMORY_CATEGORIES[suggestion.category as MemoryCategory]} · confidence {suggestion.confidence}
|
className="retro-window-inset w-full p-1 text-xs"
|
||||||
</p>
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="retro-window-inset w-full p-1 text-xs"
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
>
|
||||||
|
{Object.entries(MEMORY_CATEGORIES).map(([key, label]) => (
|
||||||
|
<option key={key} value={key}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<textarea
|
||||||
|
className="retro-window-inset w-full p-1 text-xs min-h-20"
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="font-bold">{suggestion.title}</p>
|
||||||
|
<p className="text-xs mt-1">{suggestion.content}</p>
|
||||||
|
<p className="text-[10px] text-[var(--warm-grey)]">
|
||||||
|
{MEMORY_CATEGORIES[suggestion.category as MemoryCategory]} · confidence {suggestion.confidence}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className="flex gap-2 mt-2">
|
<div className="flex gap-2 mt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="retro-btn retro-btn-primary text-xs"
|
className="retro-btn retro-btn-primary text-xs"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await fetch(`/api/ai/memory/suggestions/${suggestion.id}/accept`, { method: "POST" });
|
await fetch(`/api/ai/memory/suggestions/${suggestion.id}/accept`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(editing ? { title, content, category } : {}),
|
||||||
|
});
|
||||||
onChange();
|
onChange();
|
||||||
}}
|
}}
|
||||||
|
disabled={!title.trim() || !content.trim()}
|
||||||
>
|
>
|
||||||
Accept
|
Accept
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="retro-btn text-xs"
|
||||||
|
onClick={() => setEditing((value) => !value)}
|
||||||
|
>
|
||||||
|
{editing ? "Cancel edit" : "Edit"}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="retro-btn text-xs"
|
className="retro-btn text-xs"
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ type ChatSessionData = {
|
|||||||
messages: MentorChatMessage[];
|
messages: MentorChatMessage[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SendChatResponse = {
|
||||||
|
memorySuggestions?: unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
export function useMentorChat(sessionId: string | null) {
|
export function useMentorChat(sessionId: string | null) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -54,7 +58,7 @@ export function useMentorChat(sessionId: string | null) {
|
|||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
throw new Error(body.error ?? "Send failed");
|
throw new Error(body.error ?? "Send failed");
|
||||||
}
|
}
|
||||||
return res.json();
|
return res.json() as Promise<SendChatResponse>;
|
||||||
},
|
},
|
||||||
onMutate: (content) => {
|
onMutate: (content) => {
|
||||||
setInput("");
|
setInput("");
|
||||||
@@ -64,6 +68,8 @@ export function useMentorChat(sessionId: string | null) {
|
|||||||
setPendingState(null);
|
setPendingState(null);
|
||||||
queryClient.invalidateQueries({ queryKey: ["chat-session", sessionId] });
|
queryClient.invalidateQueries({ queryKey: ["chat-session", sessionId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["chat-sessions"] });
|
queryClient.invalidateQueries({ queryKey: ["chat-sessions"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["ai-memory-suggestions"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["ai-knows"] });
|
||||||
},
|
},
|
||||||
onError: (_err, content) => {
|
onError: (_err, content) => {
|
||||||
setPendingState(createPendingStateOnError(content));
|
setPendingState(createPendingStateOnError(content));
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "./library-context";
|
} from "./library-context";
|
||||||
import { listMemories, getProfileSummary } from "./ai-memory";
|
import { listMemories, getProfileSummary } from "./ai-memory";
|
||||||
import { MEMORY_CATEGORIES, type MemoryCategory } from "@adventureos/shared";
|
import { MEMORY_CATEGORIES, type MemoryCategory } from "@adventureos/shared";
|
||||||
|
import { extractMemoryCandidates } from "./memory-extraction";
|
||||||
|
|
||||||
export async function listChatSessions(userId: string) {
|
export async function listChatSessions(userId: string) {
|
||||||
return db
|
return db
|
||||||
@@ -57,11 +58,14 @@ export async function sendChatMessage(userId: string, sessionId: string, content
|
|||||||
|
|
||||||
const { session, messages } = sessionData;
|
const { session, messages } = sessionData;
|
||||||
|
|
||||||
await db.insert(aiChatMessages).values({
|
const [userMsg] = await db
|
||||||
sessionId,
|
.insert(aiChatMessages)
|
||||||
role: "user",
|
.values({
|
||||||
content: content.trim(),
|
sessionId,
|
||||||
});
|
role: "user",
|
||||||
|
content: content.trim(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
const availability = await getAiAvailability(userId);
|
const availability = await getAiAvailability(userId);
|
||||||
const ctx = await buildMentorContext(userId, {
|
const ctx = await buildMentorContext(userId, {
|
||||||
@@ -83,7 +87,7 @@ export async function sendChatMessage(userId: string, sessionId: string, content
|
|||||||
|
|
||||||
let reply: string;
|
let reply: string;
|
||||||
let offline = false;
|
let offline = false;
|
||||||
let memoryIdsUsed = ctx.memoryIds;
|
const memoryIdsUsed = ctx.memoryIds;
|
||||||
|
|
||||||
if (!availability.canUse || !availability.online) {
|
if (!availability.canUse || !availability.online) {
|
||||||
offline = true;
|
offline = true;
|
||||||
@@ -116,12 +120,17 @@ export async function sendChatMessage(userId: string, sessionId: string, content
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
const memorySuggestions = await extractMemoryCandidates(userId, "chat", content, {
|
||||||
|
type: "ai_chat",
|
||||||
|
id: userMsg.id,
|
||||||
|
});
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(aiChatSessions)
|
.update(aiChatSessions)
|
||||||
.set({ updatedAt: new Date(), title: session.title === "New conversation" ? truncateTitle(content) : session.title })
|
.set({ updatedAt: new Date(), title: session.title === "New conversation" ? truncateTitle(content) : session.title })
|
||||||
.where(eq(aiChatSessions.id, sessionId));
|
.where(eq(aiChatSessions.id, sessionId));
|
||||||
|
|
||||||
return { message: assistantMsg, reply, memoryIdsUsed, offline };
|
return { message: assistantMsg, reply, memoryIdsUsed, offline, memorySuggestions };
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncateTitle(text: string): string {
|
function truncateTitle(text: string): string {
|
||||||
|
|||||||
@@ -243,20 +243,41 @@ export async function createSuggestion(
|
|||||||
confidence?: number;
|
confidence?: number;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
if (!(input.category in MEMORY_CATEGORIES)) {
|
||||||
|
throw new Error("Invalid memory category");
|
||||||
|
}
|
||||||
|
|
||||||
const settings = await getMemoryLearningSettings(userId);
|
const settings = await getMemoryLearningSettings(userId);
|
||||||
if (!settings.learningEnabled) return null;
|
if (!settings.learningEnabled) return null;
|
||||||
|
|
||||||
const pending = await listSuggestions(userId, "pending");
|
const pending = await listSuggestions(userId, "pending");
|
||||||
if (pending.length >= settings.maxPendingSuggestions) return null;
|
if (pending.length >= settings.maxPendingSuggestions) return null;
|
||||||
|
|
||||||
|
if (pending.some((s) => isSimilarMemoryText(input, s))) return null;
|
||||||
|
|
||||||
|
const exactTitle = await db
|
||||||
|
.select()
|
||||||
|
.from(aiMemories)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(aiMemories.userId, userId),
|
||||||
|
eq(aiMemories.category, input.category),
|
||||||
|
isNull(aiMemories.archivedAt),
|
||||||
|
ilike(aiMemories.title, input.title)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (exactTitle.length > 0) return null;
|
||||||
|
|
||||||
const dup = await db
|
const dup = await db
|
||||||
.select()
|
.select()
|
||||||
.from(aiMemories)
|
.from(aiMemories)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(aiMemories.userId, userId),
|
eq(aiMemories.userId, userId),
|
||||||
|
eq(aiMemories.category, input.category),
|
||||||
isNull(aiMemories.archivedAt),
|
isNull(aiMemories.archivedAt),
|
||||||
ilike(aiMemories.content, `%${input.content.slice(0, 40)}%`)
|
ilike(aiMemories.content, `%${keyPhrase(input.content)}%`)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
@@ -278,6 +299,40 @@ export async function createSuggestion(
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSimilarMemoryText(
|
||||||
|
input: { category: MemoryCategory; title: string; content: string },
|
||||||
|
existing: { category: string; title: string; content: string }
|
||||||
|
) {
|
||||||
|
if (input.category !== existing.category) return false;
|
||||||
|
const inputTitle = normalizeMemoryText(input.title);
|
||||||
|
const existingTitle = normalizeMemoryText(existing.title);
|
||||||
|
const inputContent = normalizeMemoryText(input.content);
|
||||||
|
const existingContent = normalizeMemoryText(existing.content);
|
||||||
|
|
||||||
|
return (
|
||||||
|
inputTitle === existingTitle ||
|
||||||
|
inputContent === existingContent ||
|
||||||
|
inputContent.includes(existingTitle) ||
|
||||||
|
existingContent.includes(inputTitle) ||
|
||||||
|
inputContent.includes(keyPhrase(existing.content)) ||
|
||||||
|
existingContent.includes(keyPhrase(input.content))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMemoryText(value: string) {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\b7\s*a\.?\s*m\.?\b/g, "7am")
|
||||||
|
.replace(/[^a-z0-9]+/g, " ")
|
||||||
|
.replace(/\b(the|a|an|user|users|the user|to|is|are|be)\b/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyPhrase(value: string) {
|
||||||
|
return normalizeMemoryText(value).split(" ").filter(Boolean).slice(0, 8).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
export async function markMemoriesUsed(userId: string, memoryIds: string[]) {
|
export async function markMemoriesUsed(userId: string, memoryIds: string[]) {
|
||||||
if (memoryIds.length === 0) return;
|
if (memoryIds.length === 0) return;
|
||||||
await db
|
await db
|
||||||
|
|||||||
@@ -524,6 +524,7 @@ export async function generateChatReply(
|
|||||||
const prompt = [
|
const prompt = [
|
||||||
input.history ? `Recent conversation:\n${input.history}\n` : "",
|
input.history ? `Recent conversation:\n${input.history}\n` : "",
|
||||||
`User: ${input.userMessage}`,
|
`User: ${input.userMessage}`,
|
||||||
|
"If the user asks whether you can learn about them, explain that AdventureOS can suggest memories from goals, preferences, routines, worries, and direct remember requests; the user can review, edit, accept, reject, or ignore suggestions before they become saved memory.",
|
||||||
"Respond as the mentor in plain text (no JSON). Keep it concise for a local model.",
|
"Respond as the mentor in plain text (no JSON). Keep it concise for a local model.",
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|||||||
137
apps/web/src/lib/services/memory-extraction.test.ts
Normal file
137
apps/web/src/lib/services/memory-extraction.test.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
extractDeterministicMemoryCandidates,
|
||||||
|
extractMemoryCandidates,
|
||||||
|
} from "./memory-extraction";
|
||||||
|
|
||||||
|
const createSuggestion = vi.fn();
|
||||||
|
let learningEnabled = true;
|
||||||
|
|
||||||
|
vi.mock("./ai-memory", () => ({
|
||||||
|
createSuggestion: (...args: unknown[]) => createSuggestion(...args),
|
||||||
|
getMemoryLearningSettings: vi.fn(async () => ({
|
||||||
|
learningEnabled,
|
||||||
|
autoSuggestEnabled: true,
|
||||||
|
requireApproval: true,
|
||||||
|
allowedCategories: [
|
||||||
|
"current_goals",
|
||||||
|
"likes",
|
||||||
|
"dislikes",
|
||||||
|
"learning_interests",
|
||||||
|
"exercise_preferences",
|
||||||
|
"ai_tone",
|
||||||
|
"worries",
|
||||||
|
],
|
||||||
|
suggestAfterReflection: true,
|
||||||
|
suggestAfterChat: true,
|
||||||
|
maxPendingSuggestions: 10,
|
||||||
|
minDaysBetweenNudges: 3,
|
||||||
|
allowSensitiveCategories: false,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./ai-config", () => ({
|
||||||
|
getAiBehaviorConfig: vi.fn(async () => ({ enabled: true })),
|
||||||
|
getAiProviderConfig: vi.fn(async () => ({ enabled: true, model: "llama3.1:8b", timeoutMs: 1 })),
|
||||||
|
generateTextWithFallback: vi.fn(async () => {
|
||||||
|
throw new Error("AI extractor failed");
|
||||||
|
}),
|
||||||
|
buildSystemPrompt: vi.fn(() => "system"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./ai-templates", () => ({
|
||||||
|
getTemplateBody: vi.fn(async () => "core"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("extractDeterministicMemoryCandidates", () => {
|
||||||
|
it("extracts the wake-up goal regression case", () => {
|
||||||
|
const [candidate] = extractDeterministicMemoryCandidates(
|
||||||
|
"One of my goals for the next coming weeks is to wake up at 7 am every morning"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(candidate).toMatchObject({
|
||||||
|
category: "current_goals",
|
||||||
|
title: "Wake Up at 7am Every Morning",
|
||||||
|
confidence: 0.9,
|
||||||
|
});
|
||||||
|
expect(candidate.content).toContain("goals for the next few weeks");
|
||||||
|
expect(candidate.content).toContain("wake up at 7 am every morning");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts learning preferences from likes", () => {
|
||||||
|
const [candidate] = extractDeterministicMemoryCandidates("I like learning with examples");
|
||||||
|
|
||||||
|
expect(candidate).toMatchObject({
|
||||||
|
category: "learning_interests",
|
||||||
|
title: "Learning with Examples",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts dislikes", () => {
|
||||||
|
const [candidate] = extractDeterministicMemoryCandidates("I dislike too many tasks at once");
|
||||||
|
|
||||||
|
expect(candidate).toMatchObject({
|
||||||
|
category: "dislikes",
|
||||||
|
title: "Too Many Tasks at Once",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts ordinary worries as pending memory candidates", () => {
|
||||||
|
const [candidate] = extractDeterministicMemoryCandidates("I get worried when I fall behind");
|
||||||
|
|
||||||
|
expect(candidate).toMatchObject({
|
||||||
|
category: "worries",
|
||||||
|
title: "Fall Behind",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not extract vague chatter", () => {
|
||||||
|
expect(extractDeterministicMemoryCandidates("hello")).toEqual([]);
|
||||||
|
expect(extractDeterministicMemoryCandidates(" ")).toEqual([]);
|
||||||
|
expect(extractDeterministicMemoryCandidates("tell me about DNS")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractMemoryCandidates", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
learningEnabled = true;
|
||||||
|
createSuggestion.mockReset();
|
||||||
|
createSuggestion.mockImplementation(async (_userId, input) => ({
|
||||||
|
id: "suggestion-1",
|
||||||
|
...input,
|
||||||
|
status: "pending",
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates deterministic suggestions even when AI extraction fails", async () => {
|
||||||
|
const suggestions = await extractMemoryCandidates(
|
||||||
|
"user-1",
|
||||||
|
"chat",
|
||||||
|
"One of my goals for the next coming weeks is to wake up at 7 am every morning",
|
||||||
|
{ type: "ai_chat", id: "message-1" }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(createSuggestion).toHaveBeenCalledWith(
|
||||||
|
"user-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
category: "current_goals",
|
||||||
|
sourceType: "chat",
|
||||||
|
sourceRef: { type: "ai_chat", id: "message-1" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(suggestions).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not create suggestions when memory learning is disabled", async () => {
|
||||||
|
learningEnabled = false;
|
||||||
|
|
||||||
|
const suggestions = await extractMemoryCandidates(
|
||||||
|
"user-1",
|
||||||
|
"chat",
|
||||||
|
"I like learning with examples"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(suggestions).toEqual([]);
|
||||||
|
expect(createSuggestion).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { MemoryCategory, MemorySourceType } from "@adventureos/shared";
|
import type { MemoryCategory, MemorySourceType } from "@adventureos/shared";
|
||||||
import { SENSITIVE_MEMORY_CATEGORIES } from "@adventureos/shared";
|
import { MEMORY_CATEGORIES, SENSITIVE_MEMORY_CATEGORIES } from "@adventureos/shared";
|
||||||
import { parseAiJson } from "@/lib/ai/parse-json";
|
import { parseAiJson } from "@/lib/ai/parse-json";
|
||||||
import { getAiBehaviorConfig, getAiProviderConfig, generateTextWithFallback, buildSystemPrompt } from "./ai-config";
|
import { getAiBehaviorConfig, getAiProviderConfig, generateTextWithFallback, buildSystemPrompt } from "./ai-config";
|
||||||
import { createSuggestion, getMemoryLearningSettings } from "./ai-memory";
|
import { createSuggestion, getMemoryLearningSettings } from "./ai-memory";
|
||||||
@@ -19,6 +19,111 @@ const candidateSchema = z.object({
|
|||||||
.max(2),
|
.max(2),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type MemorySuggestionCandidate = {
|
||||||
|
category: MemoryCategory;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
confidence: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PatternRule = {
|
||||||
|
category: MemoryCategory;
|
||||||
|
confidence: number;
|
||||||
|
patterns: RegExp[];
|
||||||
|
titlePrefix?: string;
|
||||||
|
content: (value: string) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FILLER_PREFIX = /^(?:that\s+|to\s+|i\s+|i'm\s+|i am\s+|my\s+)/i;
|
||||||
|
|
||||||
|
const RULES: PatternRule[] = [
|
||||||
|
{
|
||||||
|
category: "current_goals",
|
||||||
|
confidence: 0.9,
|
||||||
|
patterns: [
|
||||||
|
/^(?:one of my goals|my current goal|my goal|a goal of mine)(?:\s+for\s+.+?)?\s+is\s+(.+)$/i,
|
||||||
|
/^i want to\s+(.+)$/i,
|
||||||
|
/^i'm trying to\s+(.+)$/i,
|
||||||
|
/^i am trying to\s+(.+)$/i,
|
||||||
|
/^i want to be more\s+(.+)$/i,
|
||||||
|
/^my current focus is\s+(.+)$/i,
|
||||||
|
],
|
||||||
|
content: (value) => `One of the user's current goals is to ${ensureVerbPhrase(value)}.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "likes",
|
||||||
|
confidence: 0.85,
|
||||||
|
patterns: [/^i like\s+(.+)$/i],
|
||||||
|
content: (value) => `The user likes ${lowerFirst(value)}.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "dislikes",
|
||||||
|
confidence: 0.85,
|
||||||
|
patterns: [/^i dislike\s+(.+)$/i, /^i don't like\s+(.+)$/i, /^i do not like\s+(.+)$/i],
|
||||||
|
content: (value) => `The user dislikes ${lowerFirst(value)}.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "ai_tone",
|
||||||
|
confidence: 0.9,
|
||||||
|
patterns: [/^i prefer\s+(short explanations|direct explanations|concise explanations|brief explanations|.+\s+explanations)$/i, /^i want the ai to\s+(.+)$/i],
|
||||||
|
content: (value) =>
|
||||||
|
/\bexplanations\b/i.test(value)
|
||||||
|
? `The user prefers ${lowerFirst(value)}.`
|
||||||
|
: `The user wants the AI to ${ensureVerbPhrase(value)}.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "worries",
|
||||||
|
confidence: 0.8,
|
||||||
|
patterns: [/^i get worried when\s+(.+)$/i, /^i'm worried about\s+(.+)$/i, /^i am worried about\s+(.+)$/i],
|
||||||
|
content: (value) => `The user has a worry or concern about ${lowerFirst(value)}.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "personal_context",
|
||||||
|
confidence: 0.75,
|
||||||
|
patterns: [/^i struggle with\s+(.+)$/i, /^i need help with\s+(.+)$/i],
|
||||||
|
content: (value) => `The user needs support with ${lowerFirst(value)}.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "personal_context",
|
||||||
|
confidence: 0.95,
|
||||||
|
patterns: [/^(?:remember that|save this:?|add this to memory:?)(.+)$/i],
|
||||||
|
titlePrefix: "Remember",
|
||||||
|
content: (value) => `The user explicitly asked the app to remember that ${stripTrailingPunctuation(value)}.`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const LEARNING_HINTS = /\b(learn|learning|examples?|explanations?|teach|lesson)\b/i;
|
||||||
|
const EXERCISE_HINTS = /\b(exercise|work out|workout|run|gym|walk|train)\b/i;
|
||||||
|
const UNSPECIFIC_MESSAGES = /^(hello|hi|hey|thanks|thank you|what can you do\??|that's interesting|that is interesting)$/i;
|
||||||
|
|
||||||
|
export function extractDeterministicMemoryCandidates(sourceText: string): MemorySuggestionCandidate[] {
|
||||||
|
const normalized = normalizeInput(sourceText);
|
||||||
|
if (!normalized || normalized.length < 8 || UNSPECIFIC_MESSAGES.test(normalized)) return [];
|
||||||
|
|
||||||
|
for (const rule of RULES) {
|
||||||
|
for (const pattern of rule.patterns) {
|
||||||
|
const match = normalized.match(pattern);
|
||||||
|
const rawValue = match?.[1]?.trim();
|
||||||
|
if (!rawValue) continue;
|
||||||
|
|
||||||
|
const value = cleanValue(rawValue);
|
||||||
|
if (!isDurableEnough(value)) return [];
|
||||||
|
|
||||||
|
const category = refineCategory(rule.category, normalized, value);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
category,
|
||||||
|
title: buildTitle(value, rule.titlePrefix),
|
||||||
|
content: refineContent(rule.content(value), normalized),
|
||||||
|
confidence: rule.confidence,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
export async function extractMemoryCandidates(
|
export async function extractMemoryCandidates(
|
||||||
userId: string,
|
userId: string,
|
||||||
sourceType: MemorySourceType,
|
sourceType: MemorySourceType,
|
||||||
@@ -26,18 +131,52 @@ export async function extractMemoryCandidates(
|
|||||||
sourceRef?: { type: string; id?: string; date?: string }
|
sourceRef?: { type: string; id?: string; date?: string }
|
||||||
) {
|
) {
|
||||||
const settings = await getMemoryLearningSettings(userId);
|
const settings = await getMemoryLearningSettings(userId);
|
||||||
if (!settings.learningEnabled || !settings.autoSuggestEnabled) return [];
|
if (
|
||||||
|
!settings.learningEnabled ||
|
||||||
|
!settings.autoSuggestEnabled ||
|
||||||
|
(sourceType === "chat" && !settings.suggestAfterChat)
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const behavior = await getAiBehaviorConfig(userId);
|
const results = [];
|
||||||
const providerConfig = await getAiProviderConfig(userId);
|
const deterministic = extractDeterministicMemoryCandidates(sourceText);
|
||||||
if (!behavior.enabled || !providerConfig.enabled) return [];
|
for (const c of deterministic) {
|
||||||
|
if (!settings.allowedCategories.includes(c.category)) continue;
|
||||||
|
if (
|
||||||
|
SENSITIVE_MEMORY_CATEGORIES.includes(c.category) &&
|
||||||
|
!settings.allowSensitiveCategories
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const row = await createSuggestion(userId, {
|
||||||
|
category: c.category,
|
||||||
|
title: c.title,
|
||||||
|
content: c.content,
|
||||||
|
sourceType,
|
||||||
|
sourceRef,
|
||||||
|
confidence: c.confidence,
|
||||||
|
});
|
||||||
|
if (row) results.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
let behavior: Awaited<ReturnType<typeof getAiBehaviorConfig>>;
|
||||||
|
let providerConfig: Awaited<ReturnType<typeof getAiProviderConfig>>;
|
||||||
|
try {
|
||||||
|
behavior = await getAiBehaviorConfig(userId);
|
||||||
|
providerConfig = await getAiProviderConfig(userId);
|
||||||
|
} catch {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
if (!behavior.enabled || !providerConfig.enabled) return results;
|
||||||
|
|
||||||
const prompt = `From this user text, suggest 0-2 personal memory facts the app could remember (only clear patterns, not guesses).
|
const prompt = `From this user text, suggest 0-2 personal memory facts the app could remember (only clear patterns, not guesses).
|
||||||
User text:
|
User text:
|
||||||
"""
|
"""
|
||||||
${sourceText.slice(0, 1500)}
|
${sourceText.slice(0, 1500)}
|
||||||
"""
|
"""
|
||||||
Return JSON: { "candidates": [{ "category": "likes|motivators|current_goals|reading_preferences|learning_interests|worries|...", "title": "short label", "content": "one sentence fact", "confidence": 0.0-1.0, "sensitivity": "normal|private|sensitive" }] }
|
Use only these categories: ${Object.keys(MEMORY_CATEGORIES).join(", ")}.
|
||||||
|
Return JSON: { "candidates": [{ "category": "likes|motivators|current_goals|reading_preferences|learning_interests|worries|...", "title": "short label", "content": "one sentence fact about the user", "confidence": 0.0-1.0, "sensitivity": "normal|private|sensitive" }] }
|
||||||
If nothing clear, return { "candidates": [] }.`;
|
If nothing clear, return { "candidates": [] }.`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -57,9 +196,8 @@ If nothing clear, return { "candidates": [] }.`;
|
|||||||
);
|
);
|
||||||
|
|
||||||
const parsed = candidateSchema.safeParse(parseAiJson(res.text));
|
const parsed = candidateSchema.safeParse(parseAiJson(res.text));
|
||||||
if (!parsed.success) return [];
|
if (!parsed.success) return results;
|
||||||
|
|
||||||
const results = [];
|
|
||||||
for (const c of parsed.data.candidates) {
|
for (const c of parsed.data.candidates) {
|
||||||
if (c.confidence < 0.6) continue;
|
if (c.confidence < 0.6) continue;
|
||||||
if (!settings.allowedCategories.includes(c.category as MemoryCategory)) continue;
|
if (!settings.allowedCategories.includes(c.category as MemoryCategory)) continue;
|
||||||
@@ -81,7 +219,7 @@ If nothing clear, return { "candidates": [] }.`;
|
|||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return results;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,3 +227,68 @@ async function getTemplateBody(userId: string) {
|
|||||||
const { getTemplateBody: getTpl } = await import("./ai-templates");
|
const { getTemplateBody: getTpl } = await import("./ai-templates");
|
||||||
return getTpl(userId, "system_core");
|
return getTpl(userId, "system_core");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeInput(value: string): string {
|
||||||
|
return value.trim().replace(/[’‘]/g, "'").replace(/\s+/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanValue(value: string): string {
|
||||||
|
return stripTrailingPunctuation(value.replace(FILLER_PREFIX, "").trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripTrailingPunctuation(value: string): string {
|
||||||
|
return value.trim().replace(/[.!?]+$/g, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDurableEnough(value: string): boolean {
|
||||||
|
const lower = value.toLowerCase();
|
||||||
|
if (value.length < 4) return false;
|
||||||
|
if (/^(this|that|it|stuff|things|more|better)$/i.test(lower)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refineCategory(category: MemoryCategory, fullText: string, value: string): MemoryCategory {
|
||||||
|
const combined = `${fullText} ${value}`;
|
||||||
|
if (category === "likes" && LEARNING_HINTS.test(combined)) return "learning_interests";
|
||||||
|
if (category === "current_goals" && EXERCISE_HINTS.test(combined)) return "exercise_preferences";
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refineContent(content: string, fullText: string): string {
|
||||||
|
if (/one of my goals/i.test(fullText) && /next coming weeks|next few weeks/i.test(fullText)) {
|
||||||
|
return content.replace("current goals is to", "goals for the next few weeks is to");
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureVerbPhrase(value: string): string {
|
||||||
|
const cleaned = lowerFirst(stripTrailingPunctuation(value));
|
||||||
|
if (/^(be|build|wake|read|exercise|work|study|learn|sleep|get|start|stop|finish|practice|focus)\b/i.test(cleaned)) {
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lowerFirst(value: string): string {
|
||||||
|
return value.charAt(0).toLowerCase() + value.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTitle(value: string, prefix?: string): string {
|
||||||
|
const clean = stripTrailingPunctuation(value)
|
||||||
|
.replace(/\b7\s*a\.?\s*m\.?\b/gi, "7am")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
const title = clean
|
||||||
|
.replace(/^(?:to\s+)/i, "")
|
||||||
|
.split(" ")
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((word, index) => {
|
||||||
|
if (/^\d/.test(word)) return word;
|
||||||
|
if (index > 0 && /^(at|to|with|for|and|or|the|a|an|of)$/i.test(word)) {
|
||||||
|
return word.toLowerCase();
|
||||||
|
}
|
||||||
|
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
return prefix ? `${prefix}: ${title}` : title;
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ export const MEMORY_SENSITIVITY_LEVELS = ["normal", "private", "sensitive"] as c
|
|||||||
export type MemorySensitivity = (typeof MEMORY_SENSITIVITY_LEVELS)[number];
|
export type MemorySensitivity = (typeof MEMORY_SENSITIVITY_LEVELS)[number];
|
||||||
|
|
||||||
export const SENSITIVE_MEMORY_CATEGORIES: MemoryCategory[] = [
|
export const SENSITIVE_MEMORY_CATEGORIES: MemoryCategory[] = [
|
||||||
"worries",
|
|
||||||
"personal_context",
|
"personal_context",
|
||||||
"boundaries",
|
"boundaries",
|
||||||
];
|
];
|
||||||
@@ -116,7 +115,7 @@ export const DEFAULT_AI_MEMORY_LEARNING: AiMemoryLearningSettings = {
|
|||||||
(c) => !SENSITIVE_MEMORY_CATEGORIES.includes(c)
|
(c) => !SENSITIVE_MEMORY_CATEGORIES.includes(c)
|
||||||
),
|
),
|
||||||
suggestAfterReflection: true,
|
suggestAfterReflection: true,
|
||||||
suggestAfterChat: false,
|
suggestAfterChat: true,
|
||||||
maxPendingSuggestions: 10,
|
maxPendingSuggestions: 10,
|
||||||
minDaysBetweenNudges: 3,
|
minDaysBetweenNudges: 3,
|
||||||
allowSensitiveCategories: false,
|
allowSensitiveCategories: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user