This commit is contained in:
@@ -21,6 +21,12 @@ type ChatSessionData = {
|
||||
|
||||
type SendChatResponse = {
|
||||
memorySuggestions?: unknown[];
|
||||
memoryAction?: {
|
||||
type: string;
|
||||
intent?: string;
|
||||
status?: string;
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function useMentorChat(sessionId: string | null) {
|
||||
@@ -64,12 +70,18 @@ export function useMentorChat(sessionId: string | null) {
|
||||
setInput("");
|
||||
setPendingState(createPendingStateOnSend(content));
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: (data) => {
|
||||
setPendingState(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["chat-session", sessionId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["chat-sessions"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-memory-suggestions"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-knows"] });
|
||||
if (
|
||||
data.memoryAction?.type === "memory_created" ||
|
||||
data.memoryAction?.type === "memory_suggestion_accepted"
|
||||
) {
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-memory"] });
|
||||
}
|
||||
},
|
||||
onError: (_err, content) => {
|
||||
setPendingState(createPendingStateOnError(content));
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { listMemories, getProfileSummary } from "./ai-memory";
|
||||
import { MEMORY_CATEGORIES, type MemoryCategory } from "@adventureos/shared";
|
||||
import { extractMemoryCandidates } from "./memory-extraction";
|
||||
import { buildMemoryActionReply, performChatMemoryAction } from "./chat-memory-actions";
|
||||
|
||||
export async function listChatSessions(userId: string) {
|
||||
return db
|
||||
@@ -67,6 +68,9 @@ export async function sendChatMessage(userId: string, sessionId: string, content
|
||||
})
|
||||
.returning();
|
||||
|
||||
const memoryAction = await performChatMemoryAction(userId, sessionId, userMsg, messages);
|
||||
const authoritativeMemoryReply = buildMemoryActionReply(memoryAction);
|
||||
|
||||
const availability = await getAiAvailability(userId);
|
||||
const ctx = await buildMentorContext(userId, {
|
||||
userMessage: content,
|
||||
@@ -89,7 +93,9 @@ export async function sendChatMessage(userId: string, sessionId: string, content
|
||||
let offline = false;
|
||||
const memoryIdsUsed = ctx.memoryIds;
|
||||
|
||||
if (!availability.canUse || !availability.online) {
|
||||
if (authoritativeMemoryReply) {
|
||||
reply = authoritativeMemoryReply;
|
||||
} else if (!availability.canUse || !availability.online) {
|
||||
offline = true;
|
||||
reply = buildOfflineReply(content, ctx);
|
||||
} else {
|
||||
@@ -102,6 +108,7 @@ export async function sendChatMessage(userId: string, sessionId: string, content
|
||||
userMessage: content,
|
||||
contextBlock,
|
||||
history,
|
||||
memoryAction: JSON.stringify(memoryAction),
|
||||
});
|
||||
|
||||
reply =
|
||||
@@ -116,21 +123,33 @@ export async function sendChatMessage(userId: string, sessionId: string, content
|
||||
sessionId,
|
||||
role: "assistant",
|
||||
content: reply,
|
||||
metadata: { memoryIdsUsed, offline },
|
||||
metadata: { memoryIdsUsed, offline, memoryAction },
|
||||
})
|
||||
.returning();
|
||||
|
||||
const memorySuggestions = await extractMemoryCandidates(userId, "chat", content, {
|
||||
const memorySuggestions =
|
||||
memoryAction.intent === "ordinary_message"
|
||||
? await extractMemoryCandidates(userId, "chat", content, {
|
||||
type: "ai_chat",
|
||||
id: userMsg.id,
|
||||
});
|
||||
})
|
||||
: memoryAction.type === "memory_suggestion_created"
|
||||
? [
|
||||
{
|
||||
id: memoryAction.id,
|
||||
title: memoryAction.title,
|
||||
category: memoryAction.category,
|
||||
status: memoryAction.status,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
await db
|
||||
.update(aiChatSessions)
|
||||
.set({ updatedAt: new Date(), title: session.title === "New conversation" ? truncateTitle(content) : session.title })
|
||||
.where(eq(aiChatSessions.id, sessionId));
|
||||
|
||||
return { message: assistantMsg, reply, memoryIdsUsed, offline, memorySuggestions };
|
||||
return { message: assistantMsg, reply, memoryIdsUsed, offline, memorySuggestions, memoryAction };
|
||||
}
|
||||
|
||||
function truncateTitle(text: string): string {
|
||||
|
||||
@@ -241,17 +241,18 @@ export async function createSuggestion(
|
||||
sourceType: MemorySourceType;
|
||||
sourceRef?: { type: string; id?: string; date?: string };
|
||||
confidence?: number;
|
||||
}
|
||||
},
|
||||
opts: { bypassLearningSettings?: boolean } = {}
|
||||
) {
|
||||
if (!(input.category in MEMORY_CATEGORIES)) {
|
||||
throw new Error("Invalid memory category");
|
||||
}
|
||||
|
||||
const settings = await getMemoryLearningSettings(userId);
|
||||
if (!settings.learningEnabled) return null;
|
||||
if (!opts.bypassLearningSettings && !settings.learningEnabled) return null;
|
||||
|
||||
const pending = await listSuggestions(userId, "pending");
|
||||
if (pending.length >= settings.maxPendingSuggestions) return null;
|
||||
if (!opts.bypassLearningSettings && pending.length >= settings.maxPendingSuggestions) return null;
|
||||
|
||||
if (pending.some((s) => isSimilarMemoryText(input, s))) return null;
|
||||
|
||||
|
||||
@@ -503,7 +503,7 @@ export async function isAiAvailable(): Promise<boolean> {
|
||||
|
||||
export async function generateChatReply(
|
||||
userId: string,
|
||||
input: { userMessage: string; contextBlock: string; history?: string }
|
||||
input: { userMessage: string; contextBlock: string; history?: string; memoryAction?: string }
|
||||
): Promise<string | null> {
|
||||
const behavior = await getAiBehaviorConfig(userId);
|
||||
if (!behavior.enabled) return null;
|
||||
@@ -524,6 +524,8 @@ export async function generateChatReply(
|
||||
const prompt = [
|
||||
input.history ? `Recent conversation:\n${input.history}\n` : "",
|
||||
`User: ${input.userMessage}`,
|
||||
input.memoryAction ? `Application memoryAction result: ${input.memoryAction}` : "",
|
||||
"Memory guardrail: You must not claim that a memory has been saved, added, approved, rejected, deleted, or changed unless the application memoryAction result confirms it. If no memoryAction result confirms persistence, do not imply that memory persistence happened.",
|
||||
"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.",
|
||||
]
|
||||
|
||||
40
apps/web/src/lib/services/chat-memory-actions.test.ts
Normal file
40
apps/web/src/lib/services/chat-memory-actions.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildMemoryActionReply, type ChatMemoryActionResult } from "./chat-memory-actions";
|
||||
|
||||
describe("buildMemoryActionReply", () => {
|
||||
it("acknowledges pending suggestions without claiming confirmed save", () => {
|
||||
const reply = buildMemoryActionReply({
|
||||
type: "memory_suggestion_created",
|
||||
intent: "explicit_save_memory",
|
||||
title: "Wake Up at 7am Every Morning",
|
||||
category: "current_goals",
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
expect(reply).toBe('I\'ve created a memory suggestion for review: "Wake Up at 7am Every Morning".');
|
||||
});
|
||||
|
||||
it("acknowledges accepted suggestions as confirmed memory", () => {
|
||||
const reply = buildMemoryActionReply({
|
||||
type: "memory_suggestion_accepted",
|
||||
intent: "approve_pending_memory",
|
||||
title: "Wake Up at 7am Every Morning",
|
||||
category: "current_goals",
|
||||
status: "accepted",
|
||||
});
|
||||
|
||||
expect(reply).toBe("Saved - that goal memory is now part of your confirmed memory.");
|
||||
});
|
||||
|
||||
it("does not claim success on memory failures", () => {
|
||||
const action: ChatMemoryActionResult = {
|
||||
type: "memory_action_failed",
|
||||
intent: "explicit_save_memory",
|
||||
error: "database unavailable",
|
||||
};
|
||||
|
||||
expect(buildMemoryActionReply(action)).toBe(
|
||||
"I understood that you wanted to save this as a memory, but I could not save it. Please try again."
|
||||
);
|
||||
});
|
||||
});
|
||||
388
apps/web/src/lib/services/chat-memory-actions.ts
Normal file
388
apps/web/src/lib/services/chat-memory-actions.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { MemoryCategory, MemorySourceType } from "@adventureos/shared";
|
||||
import { SENSITIVE_MEMORY_CATEGORIES } from "@adventureos/shared";
|
||||
import { db, aiChatMessages, aiMemorySuggestions } from "../db";
|
||||
import {
|
||||
acceptSuggestion,
|
||||
createMemory,
|
||||
createSuggestion,
|
||||
getMemoryLearningSettings,
|
||||
listMemories,
|
||||
listSuggestions,
|
||||
rejectSuggestion,
|
||||
type MemoryInput,
|
||||
} from "./ai-memory";
|
||||
import {
|
||||
detectMemoryIntent,
|
||||
type MemoryIntent,
|
||||
type MemorySuggestionCandidate,
|
||||
} from "./memory-extraction";
|
||||
|
||||
type ChatMessage = typeof aiChatMessages.$inferSelect;
|
||||
type MemoryActionType =
|
||||
| "memory_created"
|
||||
| "memory_suggestion_created"
|
||||
| "memory_suggestion_accepted"
|
||||
| "memory_suggestion_rejected"
|
||||
| "no_memory_action_needed"
|
||||
| "memory_action_failed";
|
||||
|
||||
export type ChatMemoryActionResult = {
|
||||
type: MemoryActionType;
|
||||
intent: MemoryIntent;
|
||||
id?: string;
|
||||
title?: string;
|
||||
category?: string;
|
||||
status?: string;
|
||||
reason?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export async function performChatMemoryAction(
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
userMessage: ChatMessage,
|
||||
previousMessages: ChatMessage[]
|
||||
): Promise<ChatMemoryActionResult> {
|
||||
const detected = detectMemoryIntent(userMessage.content);
|
||||
|
||||
try {
|
||||
if (detected.intent === "explicit_save_memory") {
|
||||
if (!detected.candidate) {
|
||||
return {
|
||||
type: "memory_action_failed",
|
||||
intent: detected.intent,
|
||||
reason: "no_memory_candidate",
|
||||
error: "I understood that you wanted to save a memory, but I could not identify the memory text.",
|
||||
};
|
||||
}
|
||||
return saveExplicitMemory(userId, detected.candidate, {
|
||||
type: "chat",
|
||||
sourceRef: { type: "ai_chat", id: userMessage.id },
|
||||
});
|
||||
}
|
||||
|
||||
if (detected.intent === "approve_pending_memory") {
|
||||
const pending = await findLatestPendingSuggestionForConversation(userId, sessionId);
|
||||
if (pending) {
|
||||
const memory = await acceptSuggestion(userId, pending.id, {
|
||||
category: detected.category,
|
||||
});
|
||||
return {
|
||||
type: "memory_suggestion_accepted",
|
||||
intent: detected.intent,
|
||||
id: memory.id,
|
||||
title: memory.title,
|
||||
category: memory.category,
|
||||
status: "accepted",
|
||||
};
|
||||
}
|
||||
|
||||
const recentCandidate = findLatestExplicitCandidate(previousMessages);
|
||||
if (recentCandidate) {
|
||||
return createAndAcceptFallback(userId, recentCandidate, {
|
||||
type: "chat",
|
||||
sourceRef: { type: "ai_chat", id: userMessage.id },
|
||||
category: detected.category,
|
||||
intent: detected.intent,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: "no_memory_action_needed",
|
||||
intent: detected.intent,
|
||||
reason: "no_pending_suggestion",
|
||||
};
|
||||
}
|
||||
|
||||
if (detected.intent === "reject_pending_memory") {
|
||||
const pending = await findLatestPendingSuggestionForConversation(userId, sessionId);
|
||||
if (!pending) {
|
||||
return {
|
||||
type: "no_memory_action_needed",
|
||||
intent: detected.intent,
|
||||
reason: "no_pending_suggestion",
|
||||
};
|
||||
}
|
||||
await rejectSuggestion(userId, pending.id);
|
||||
return {
|
||||
type: "memory_suggestion_rejected",
|
||||
intent: detected.intent,
|
||||
id: pending.id,
|
||||
title: pending.title,
|
||||
category: pending.category,
|
||||
status: "rejected",
|
||||
};
|
||||
}
|
||||
|
||||
if (detected.intent === "edit_pending_memory") {
|
||||
return {
|
||||
type: "no_memory_action_needed",
|
||||
intent: detected.intent,
|
||||
reason: "edit_not_supported_in_chat",
|
||||
};
|
||||
}
|
||||
|
||||
return { type: "no_memory_action_needed", intent: detected.intent };
|
||||
} catch (error) {
|
||||
return {
|
||||
type: "memory_action_failed",
|
||||
intent: detected.intent,
|
||||
error: error instanceof Error ? error.message : "Memory action failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMemoryActionReply(action: ChatMemoryActionResult): string | null {
|
||||
if (action.type === "memory_suggestion_created") {
|
||||
return `I've created a memory suggestion for review: "${action.title}".`;
|
||||
}
|
||||
if (action.type === "memory_created") {
|
||||
return `I've saved that as a ${formatCategory(action.category)} memory: "${action.title}".`;
|
||||
}
|
||||
if (action.type === "memory_suggestion_accepted") {
|
||||
return `Saved - that ${formatCategory(action.category)} memory is now part of your confirmed memory.`;
|
||||
}
|
||||
if (action.type === "memory_suggestion_rejected") {
|
||||
return `Rejected - I did not save that memory suggestion.`;
|
||||
}
|
||||
if (action.type === "memory_action_failed") {
|
||||
return action.error?.startsWith("I understood")
|
||||
? action.error
|
||||
: "I understood that you wanted to save this as a memory, but I could not save it. Please try again.";
|
||||
}
|
||||
if (action.intent === "approve_pending_memory" && action.reason === "no_pending_suggestion") {
|
||||
return "I do not have a pending memory suggestion to approve in this chat.";
|
||||
}
|
||||
if (action.intent === "reject_pending_memory" && action.reason === "no_pending_suggestion") {
|
||||
return "I do not have a pending memory suggestion to reject in this chat.";
|
||||
}
|
||||
if (action.intent === "edit_pending_memory" && action.reason === "edit_not_supported_in_chat") {
|
||||
return "I can help with that, but I did not change the memory. Please edit the pending suggestion card before approving it.";
|
||||
}
|
||||
if (action.reason === "duplicate_memory") {
|
||||
return `I did not create a duplicate. That memory is already ${action.status === "pending" ? "pending review" : "saved"}.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function saveExplicitMemory(
|
||||
userId: string,
|
||||
candidate: MemorySuggestionCandidate,
|
||||
source: { type: MemorySourceType; sourceRef: { type: string; id?: string; date?: string } }
|
||||
): Promise<ChatMemoryActionResult> {
|
||||
const duplicate = await findDuplicate(userId, candidate);
|
||||
if (duplicate) {
|
||||
return {
|
||||
type: "no_memory_action_needed",
|
||||
intent: "explicit_save_memory",
|
||||
id: duplicate.id,
|
||||
title: duplicate.title,
|
||||
category: duplicate.category,
|
||||
status: duplicate.status,
|
||||
reason: "duplicate_memory",
|
||||
};
|
||||
}
|
||||
|
||||
const settings = await getMemoryLearningSettings(userId);
|
||||
const shouldRequireApproval =
|
||||
settings.requireApproval || SENSITIVE_MEMORY_CATEGORIES.includes(candidate.category);
|
||||
|
||||
if (!shouldRequireApproval) {
|
||||
const memory = await createMemory(userId, toMemoryInput(candidate, source.type, source.sourceRef));
|
||||
return {
|
||||
type: "memory_created",
|
||||
intent: "explicit_save_memory",
|
||||
id: memory.id,
|
||||
title: memory.title,
|
||||
category: memory.category,
|
||||
status: "confirmed",
|
||||
};
|
||||
}
|
||||
|
||||
const suggestion = await createSuggestion(
|
||||
userId,
|
||||
{
|
||||
...candidate,
|
||||
sourceType: source.type,
|
||||
sourceRef: source.sourceRef,
|
||||
},
|
||||
{ bypassLearningSettings: true }
|
||||
);
|
||||
|
||||
if (!suggestion) {
|
||||
return {
|
||||
type: "memory_action_failed",
|
||||
intent: "explicit_save_memory",
|
||||
reason: "suggestion_not_created",
|
||||
error: "I understood that you wanted to save this as a memory, but I could not save it. Please try again.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "memory_suggestion_created",
|
||||
intent: "explicit_save_memory",
|
||||
id: suggestion.id,
|
||||
title: suggestion.title,
|
||||
category: suggestion.category,
|
||||
status: suggestion.status,
|
||||
};
|
||||
}
|
||||
|
||||
async function createAndAcceptFallback(
|
||||
userId: string,
|
||||
candidate: MemorySuggestionCandidate,
|
||||
source: {
|
||||
type: MemorySourceType;
|
||||
sourceRef: { type: string; id?: string; date?: string };
|
||||
category?: MemoryCategory;
|
||||
intent: MemoryIntent;
|
||||
}
|
||||
): Promise<ChatMemoryActionResult> {
|
||||
const adjusted = source.category ? { ...candidate, category: source.category } : candidate;
|
||||
const duplicate = await findDuplicate(userId, adjusted);
|
||||
if (duplicate?.status === "confirmed") {
|
||||
return {
|
||||
type: "no_memory_action_needed",
|
||||
intent: source.intent,
|
||||
id: duplicate.id,
|
||||
title: duplicate.title,
|
||||
category: duplicate.category,
|
||||
status: duplicate.status,
|
||||
reason: "duplicate_memory",
|
||||
};
|
||||
}
|
||||
|
||||
const suggestion = await createSuggestion(
|
||||
userId,
|
||||
{
|
||||
...adjusted,
|
||||
sourceType: source.type,
|
||||
sourceRef: source.sourceRef,
|
||||
},
|
||||
{ bypassLearningSettings: true }
|
||||
);
|
||||
|
||||
if (!suggestion) {
|
||||
return {
|
||||
type: "memory_action_failed",
|
||||
intent: source.intent,
|
||||
reason: "suggestion_not_created",
|
||||
error: "I understood that you wanted to approve this memory, but I could not save it. Please try again.",
|
||||
};
|
||||
}
|
||||
|
||||
const memory = await acceptSuggestion(userId, suggestion.id);
|
||||
return {
|
||||
type: "memory_suggestion_accepted",
|
||||
intent: source.intent,
|
||||
id: memory.id,
|
||||
title: memory.title,
|
||||
category: memory.category,
|
||||
status: "accepted",
|
||||
};
|
||||
}
|
||||
|
||||
async function findLatestPendingSuggestionForConversation(userId: string, sessionId: string) {
|
||||
const messages = await db
|
||||
.select({ id: aiChatMessages.id })
|
||||
.from(aiChatMessages)
|
||||
.where(eq(aiChatMessages.sessionId, sessionId));
|
||||
const messageIds = new Set(messages.map((m) => m.id));
|
||||
|
||||
const pending = await listSuggestions(userId, "pending");
|
||||
return (
|
||||
pending.find((suggestion) => {
|
||||
const ref = suggestion.sourceRef;
|
||||
return ref?.type === "ai_chat" && !!ref.id && messageIds.has(ref.id);
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function findLatestExplicitCandidate(messages: ChatMessage[]): MemorySuggestionCandidate | null {
|
||||
for (const message of [...messages].reverse()) {
|
||||
if (message.role !== "user") continue;
|
||||
const detected = detectMemoryIntent(message.content);
|
||||
if (detected.intent === "explicit_save_memory" && detected.candidate) {
|
||||
return detected.candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function findDuplicate(userId: string, candidate: MemorySuggestionCandidate) {
|
||||
const memories = await listMemories(userId, { includeArchived: false });
|
||||
const memory = memories.find((m) => isSimilarMemory(candidate, m));
|
||||
if (memory) {
|
||||
return {
|
||||
id: memory.id,
|
||||
title: memory.title,
|
||||
category: memory.category,
|
||||
status: "confirmed",
|
||||
};
|
||||
}
|
||||
|
||||
const suggestions = await db
|
||||
.select()
|
||||
.from(aiMemorySuggestions)
|
||||
.where(and(eq(aiMemorySuggestions.userId, userId), eq(aiMemorySuggestions.status, "pending")));
|
||||
const suggestion = suggestions.find((s) => isSimilarMemory(candidate, s));
|
||||
if (suggestion) {
|
||||
return {
|
||||
id: suggestion.id,
|
||||
title: suggestion.title,
|
||||
category: suggestion.category,
|
||||
status: "pending",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function toMemoryInput(
|
||||
candidate: MemorySuggestionCandidate,
|
||||
sourceType: MemorySourceType,
|
||||
sourceRef: { type: string; id?: string; date?: string }
|
||||
): MemoryInput {
|
||||
return {
|
||||
category: candidate.category,
|
||||
title: candidate.title,
|
||||
content: candidate.content,
|
||||
confidence: candidate.confidence,
|
||||
sourceType,
|
||||
sourceRef,
|
||||
userVerified: true,
|
||||
};
|
||||
}
|
||||
|
||||
function isSimilarMemory(
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
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 formatCategory(category?: string) {
|
||||
if (category === "current_goals") return "goal";
|
||||
return category?.replace(/_/g, " ") ?? "personal";
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
detectMemoryIntent,
|
||||
extractDeterministicMemoryCandidates,
|
||||
extractMemoryCandidates,
|
||||
} from "./memory-extraction";
|
||||
@@ -92,6 +93,61 @@ describe("extractDeterministicMemoryCandidates", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectMemoryIntent", () => {
|
||||
it("detects explicit add-memory requests", () => {
|
||||
const result = detectMemoryIntent("Remember that I prefer short explanations");
|
||||
|
||||
expect(result).toMatchObject({
|
||||
intent: "explicit_save_memory",
|
||||
confidence: "high",
|
||||
});
|
||||
expect(result.candidate).toMatchObject({
|
||||
category: "ai_tone",
|
||||
title: "Short Explanations",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects the exact wake-up goal request as a current goal", () => {
|
||||
const result = detectMemoryIntent(
|
||||
"I want you to add a memory for me, and the memory will be a goal type: One of my goals for the next coming weeks is to wake up at 7 am every morning"
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
intent: "explicit_save_memory",
|
||||
category: "current_goals",
|
||||
confidence: "high",
|
||||
});
|
||||
expect(result.candidate).toMatchObject({
|
||||
category: "current_goals",
|
||||
title: "Wake Up at 7am Every Morning",
|
||||
});
|
||||
expect(result.candidate?.content).toBe(
|
||||
"One of the user's goals for the next few weeks is to wake up at 7am every morning."
|
||||
);
|
||||
});
|
||||
|
||||
it("maps approval phrases to approve_pending_memory", () => {
|
||||
expect(detectMemoryIntent("Yes, as a goal, approved.")).toMatchObject({
|
||||
intent: "approve_pending_memory",
|
||||
category: "current_goals",
|
||||
confidence: "high",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps rejection phrases to reject_pending_memory", () => {
|
||||
expect(detectMemoryIntent("No, don't remember that.")).toMatchObject({
|
||||
intent: "reject_pending_memory",
|
||||
confidence: "high",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not trigger memory actions for ordinary chat", () => {
|
||||
expect(detectMemoryIntent("What should I focus on today?")).toMatchObject({
|
||||
intent: "ordinary_message",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractMemoryCandidates", () => {
|
||||
beforeEach(() => {
|
||||
learningEnabled = true;
|
||||
|
||||
@@ -26,6 +26,20 @@ export type MemorySuggestionCandidate = {
|
||||
confidence: number;
|
||||
};
|
||||
|
||||
export type MemoryIntent =
|
||||
| "explicit_save_memory"
|
||||
| "approve_pending_memory"
|
||||
| "reject_pending_memory"
|
||||
| "edit_pending_memory"
|
||||
| "ordinary_message";
|
||||
|
||||
export type MemoryIntentResult = {
|
||||
intent: MemoryIntent;
|
||||
candidate?: MemorySuggestionCandidate;
|
||||
category?: MemoryCategory;
|
||||
confidence: "high" | "medium" | "low";
|
||||
};
|
||||
|
||||
type PatternRule = {
|
||||
category: MemoryCategory;
|
||||
confidence: number;
|
||||
@@ -95,6 +109,47 @@ const RULES: PatternRule[] = [
|
||||
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;
|
||||
const EXPLICIT_SAVE_PHRASES =
|
||||
/\b(remember that|remember this as|save this as|save this|add this to memory|add this as|add a memory|i want you to add a memory|the memory will be)\b/i;
|
||||
const APPROVAL_PHRASES =
|
||||
/\b(approved|yes approved|yes,?\s+as\s+a\s+\w+,?\s+approved|save it|confirm it|that is correct|that's correct|yes that's right|yes that is right|accept it)\b/i;
|
||||
const REJECTION_PHRASES =
|
||||
/\b(reject it|don't save that|do not save that|forget that|ignore that suggestion|no,?\s+don'?t remember that|no,?\s+do not remember that)\b/i;
|
||||
const EDIT_PHRASES = /\b(change it to|edit that|update that suggestion|make it)\b/i;
|
||||
|
||||
export function detectMemoryIntent(sourceText: string): MemoryIntentResult {
|
||||
const normalized = normalizeInput(sourceText);
|
||||
if (!normalized) return { intent: "ordinary_message", confidence: "low" };
|
||||
|
||||
if (REJECTION_PHRASES.test(normalized)) {
|
||||
return { intent: "reject_pending_memory", confidence: "high" };
|
||||
}
|
||||
|
||||
if (EXPLICIT_SAVE_PHRASES.test(normalized)) {
|
||||
const candidateText = extractExplicitMemoryText(normalized);
|
||||
const candidate = buildExplicitCandidate(candidateText, normalized);
|
||||
return {
|
||||
intent: "explicit_save_memory",
|
||||
candidate,
|
||||
category: candidate?.category ?? categoryFromMemoryCommand(normalized),
|
||||
confidence: candidate ? "high" : "medium",
|
||||
};
|
||||
}
|
||||
|
||||
if (APPROVAL_PHRASES.test(normalized)) {
|
||||
return {
|
||||
intent: "approve_pending_memory",
|
||||
category: categoryFromMemoryCommand(normalized),
|
||||
confidence: "high",
|
||||
};
|
||||
}
|
||||
|
||||
if (EDIT_PHRASES.test(normalized)) {
|
||||
return { intent: "edit_pending_memory", confidence: "medium" };
|
||||
}
|
||||
|
||||
return { intent: "ordinary_message", confidence: "low" };
|
||||
}
|
||||
|
||||
export function extractDeterministicMemoryCandidates(sourceText: string): MemorySuggestionCandidate[] {
|
||||
const normalized = normalizeInput(sourceText);
|
||||
@@ -249,16 +304,20 @@ function isDurableEnough(value: string): boolean {
|
||||
|
||||
function refineCategory(category: MemoryCategory, fullText: string, value: string): MemoryCategory {
|
||||
const combined = `${fullText} ${value}`;
|
||||
if (/\b(goal type|as a goal|one of my goals|my goals?|i want to|i'm trying to|i am trying to)\b/i.test(combined)) {
|
||||
return "current_goals";
|
||||
}
|
||||
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 {
|
||||
const normalizedTime = content.replace(/\b7\s*a\.?\s*m\.?\b/gi, "7am");
|
||||
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 normalizedTime.replace("current goals is to", "goals for the next few weeks is to");
|
||||
}
|
||||
return content;
|
||||
return normalizedTime;
|
||||
}
|
||||
|
||||
function ensureVerbPhrase(value: string): string {
|
||||
@@ -292,3 +351,64 @@ function buildTitle(value: string, prefix?: string): string {
|
||||
.join(" ");
|
||||
return prefix ? `${prefix}: ${title}` : title;
|
||||
}
|
||||
|
||||
function extractExplicitMemoryText(sourceText: string): string {
|
||||
const afterColon = sourceText.split(":").slice(1).join(":").trim();
|
||||
if (afterColon) return afterColon;
|
||||
|
||||
return sourceText
|
||||
.replace(/^.*?\b(?:remember that|remember this as|save this as|save this|add this to memory|add this as|add a memory|i want you to add a memory|the memory will be)\b/i, "")
|
||||
.replace(/^(?:for me|and|the memory will be|a|an|goal type|as a goal|goal)\b[:,\s-]*/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function buildExplicitCandidate(candidateText: string, fullText: string): MemorySuggestionCandidate | undefined {
|
||||
const durableText = stripTrailingPunctuation(candidateText);
|
||||
const cleaned = cleanValue(candidateText);
|
||||
if (!isDurableEnough(durableText) && !isDurableEnough(cleaned)) return undefined;
|
||||
|
||||
const deterministic = extractDeterministicMemoryCandidates(durableText)[0];
|
||||
if (deterministic) {
|
||||
return {
|
||||
...deterministic,
|
||||
category: refineCategory(deterministic.category, fullText, cleaned),
|
||||
content: refineContent(deterministic.content, fullText),
|
||||
confidence: Math.max(deterministic.confidence, 0.9),
|
||||
};
|
||||
}
|
||||
|
||||
const category = categoryFromMemoryCommand(fullText);
|
||||
const value = stripLeadingMemoryType(cleaned);
|
||||
if (!isDurableEnough(value)) return undefined;
|
||||
|
||||
if (category === "current_goals") {
|
||||
return {
|
||||
category,
|
||||
title: buildTitle(value),
|
||||
content: `One of the user's current goals is to ${normalizeTime(ensureVerbPhrase(value))}.`,
|
||||
confidence: 0.9,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
category,
|
||||
title: buildTitle(value),
|
||||
content: `The user explicitly asked the app to remember that ${normalizeTime(stripTrailingPunctuation(value))}.`,
|
||||
confidence: 0.9,
|
||||
};
|
||||
}
|
||||
|
||||
function categoryFromMemoryCommand(value: string): MemoryCategory {
|
||||
if (/\b(goal type|as a goal|current goal|one of my goals|my goals?|i want to|i'm trying to|i am trying to)\b/i.test(value)) {
|
||||
return "current_goals";
|
||||
}
|
||||
return "personal_context";
|
||||
}
|
||||
|
||||
function stripLeadingMemoryType(value: string): string {
|
||||
return value.replace(/^(?:goal type|as a goal|goal)\b[:,\s-]*/i, "").trim();
|
||||
}
|
||||
|
||||
function normalizeTime(value: string): string {
|
||||
return value.replace(/\b7\s*a\.?\s*m\.?\b/gi, "7am");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user