Skip to content
74 changes: 74 additions & 0 deletions apps/noter/app/api/memory/remember-one/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* Memory Remember One API — saves a single approved memory text to MemWal.
* Used by the memory panel's "Approve" flow (individual memory saves).
* Returns { id, blob_id, owner, namespace } for blockchain data display.
*/

import { rememberText } from "@/feature/note/lib/pdw-client";
import { db } from "@/shared/lib/db";
import { zkLoginSessions, walletSessions, users } from "@/shared/db/schema";
import { eq } from "drizzle-orm";

async function resolveUserKey(req: Request) {
const sessionId = req.headers.get("x-session-id");
if (!sessionId) return { key: null, accountId: null };

const [session] = await db
.select()
.from(walletSessions)
.where(eq(walletSessions.id, sessionId))
.limit(1);

if (!session?.userId || session.expiresAt < new Date()) {
const [zkSession] = await db
.select()
.from(zkLoginSessions)
.where(eq(zkLoginSessions.id, sessionId))
.limit(1);
if (!zkSession?.userId || zkSession.expiresAt < new Date()) {
return { key: null, accountId: null };
}
const [user] = await db.select().from(users).where(eq(users.id, zkSession.userId)).limit(1);
return {
key: user?.delegatePrivateKey ?? null,
accountId: user?.delegateAccountId ?? null,
};
}

const [user] = await db.select().from(users).where(eq(users.id, session.userId)).limit(1);
return {
key: user?.delegatePrivateKey ?? null,
accountId: user?.delegateAccountId ?? null,
};
}

export async function POST(req: Request) {
try {
const { text } = await req.json();

if (!text || typeof text !== "string") {
return Response.json({ error: "text is required" }, { status: 400 });
}

if (text.trim().length < 10) {
return Response.json({ error: "Text too short to remember" }, { status: 400 });
}

const { key, accountId } = await resolveUserKey(req);
if ((!key || !accountId) && (!process.env.MEMWAL_KEY || !process.env.MEMWAL_ACCOUNT_ID)) {
return Response.json(
{ error: "[MemWal] No accountId configured — sign in with Enoki or set MEMWAL_ACCOUNT_ID in .env" },
{ status: 401 },
);
}

const result = await rememberText(text, key, accountId);
return Response.json(result);
} catch (error) {
console.error("[memory/remember-one] Error:", error);
return Response.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 },
);
}
}
25 changes: 20 additions & 5 deletions apps/noter/package/feature/note/hook/use-note-memory-save.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,20 @@

import { useState } from "react";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { $getMemoryById } from "../plugins/MemoryHighlightPlugin";
import { UPDATE_MEMORY_STATUS_COMMAND } from "../plugins/MemoryHighlightPlugin";
import { $getMemoryById, UPDATE_MEMORY_STATUS_COMMAND } from "../plugins/MemoryHighlightPlugin";
import { STORAGE_KEYS } from "@/feature/auth/constant";
import type { SessionData } from "@/feature/auth/domain/type";

function getSessionId(): string | null {
if (typeof window === "undefined") return null;
try {
const raw = sessionStorage.getItem(STORAGE_KEYS.sessionId);
if (!raw) return null;
return (JSON.parse(raw) as SessionData | null)?.sessionId ?? null;
} catch {
return null;
}
}

export function useNoteMemorySave() {
const [editor] = useLexicalComposerContext();
Expand Down Expand Up @@ -47,10 +59,13 @@ export function useNoteMemorySave() {
status: "uploading",
});

// Call server-side API to remember (v2 SDK)
const response = await fetch("/api/memory/remember", {
const sessionId = getSessionId();
const response = await fetch("/api/memory/remember-one", {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
...(sessionId ? { "x-session-id": sessionId } : {}),
},
body: JSON.stringify({ text: memoryText }),
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,42 @@ export function MemoryHighlightPlugin({
if (index === -1) {
index = findUnusedOccurrence(trimmedMemoryText);
if (index !== -1) {
matchedText = trimmedMemoryText; // We matched the trimmed version!
matchedText = trimmedMemoryText;
}
}

// If that fails, try fuzzy match: case-insensitive search,
// then find the best overlapping substring between LLM text and editor text
if (index === -1) {
const lowerFull = fullText.toLowerCase();
const lowerMemory = trimmedMemoryText.toLowerCase();

// Strategy 1: case-insensitive exact match
const ciIndex = lowerFull.indexOf(lowerMemory);
if (ciIndex !== -1 && !usedPositions.has(ciIndex)) {
index = ciIndex;
matchedText = fullText.slice(ciIndex, ciIndex + trimmedMemoryText.length);
}

// Strategy 2: find longest common substring (min 10 chars)
if (index === -1) {
let best = { start: -1, len: 0 };
for (let i = 0; i < lowerFull.length; i++) {
for (let j = 0; j < lowerMemory.length; j++) {
if (lowerFull[i] !== lowerMemory[j]) continue;
let k = 0;
while (i + k < lowerFull.length && j + k < lowerMemory.length && lowerFull[i + k] === lowerMemory[j + k]) {
k++;
}
if (k > best.len) {
best = { start: i, len: k };
}
}
}
if (best.len >= 10 && !usedPositions.has(best.start)) {
index = best.start;
matchedText = fullText.slice(best.start, best.start + best.len);
}
}
}

Expand Down
22 changes: 12 additions & 10 deletions apps/noter/package/feature/note/ui/memory-hover-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Card, CardContent } from "@/shared/components/ui/card";
import { Separator } from "@/shared/components/ui/separator";
import type { MemoryData } from "@/shared/db/type";
import { cn } from "@/shared/lib/utils";
import { ZKLOGIN_CONFIG } from "@/feature/auth/constant";
import {
AlertCircle,
CheckCircle2,
Expand Down Expand Up @@ -205,16 +206,17 @@ export function MemoryHoverPreview({
<span className="text-xs text-muted-foreground font-mono truncate">
{memory.memwalMemoryId.slice(0, 16)}...
</span>
<button
onClick={(e) => {
e.stopPropagation();
// TODO: Open in Sui Explorer
navigator.clipboard.writeText(memory.memwalMemoryId!);
}}
className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline"
>
<ExternalLink className="h-3 w-3" />
</button>
{memory.memwalBlobId && (
<a
href={`https://walruscan.com/${ZKLOGIN_CONFIG.network}/blob/${memory.memwalBlobId}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline"
>
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>

{/* Blob ID */}
Expand Down
24 changes: 12 additions & 12 deletions apps/noter/package/feature/note/ui/memory-panel-enhanced.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
import { forwardRef, useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useNoteMemorySave } from "../hook/use-note-memory-save";
import { ZKLOGIN_CONFIG } from "@/feature/auth/constant";
import { $getAllMemories, REMOVE_MEMORY_HIGHLIGHT_COMMAND, UPDATE_MEMORY_STATUS_COMMAND } from "../plugins/MemoryHighlightPlugin";

const STATUS_CONFIGS = {
Expand Down Expand Up @@ -433,18 +434,17 @@ const MemoryCard = forwardRef<HTMLDivElement, MemoryCardProps>(
<div className="space-y-1 text-xs text-muted-foreground font-mono">
<div className="flex items-center justify-between gap-2">
<span className="truncate">Memory: {memory.memwalMemoryId.slice(0, 12)}...</span>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={(e) => {
e.stopPropagation();
// TODO: Add link to Sui Explorer
toast.info("Opening in Sui Explorer...");
}}
>
<ExternalLink className="h-3 w-3" />
</Button>
{memory.memwalBlobId && (
<a
href={`https://walruscan.com/${ZKLOGIN_CONFIG.network}/blob/${memory.memwalBlobId}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="shrink-0 p-0.5 text-blue-600 dark:text-blue-400 hover:underline"
>
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
{memory.memwalBlobId && (
<div className="truncate">Blob: {memory.memwalBlobId.slice(0, 12)}...</div>
Expand Down
9 changes: 8 additions & 1 deletion apps/noter/package/feature/note/ui/note-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ import { MarketingBorder } from "@/package/shared/components/border";
import { Button } from "@/shared/components/ui/button";
import { Check, Loader2, Save, XCircle } from "lucide-react";
import { useNote } from "../hook/use-note";
import { MemoryHighlightNode } from "../nodes/MemoryHighlightNode";
import { MemoryHighlightPlugin } from "../plugins/MemoryHighlightPlugin";
import { MemoryPanelEnhanced } from "./memory-panel-enhanced";
import { MemoryDetectButton } from "./memory-detect-button";

// ════════════════════════════════════════════════════════════════════════════
// EDITOR CONFIG
Expand All @@ -56,6 +60,7 @@ const editorConfig = {
TableCellNode,
TableRowNode,
HorizontalRuleNode,
MemoryHighlightNode,
],
onError(error: Error) {
console.error("Lexical error:", error);
Expand Down Expand Up @@ -249,6 +254,7 @@ export function NoteEditor({ noteId }: NoteEditorProps) {
{/* Toolbar */}
<div className="border-b p-4 flex items-center justify-start gap-3">
<SaveNoteButton onSaveToDb={saveImmediate} />
<MemoryDetectButton noteId={noteId} />
</div>

{/* Editor */}
Expand All @@ -275,10 +281,11 @@ export function NoteEditor({ noteId }: NoteEditorProps) {
<CodeHighlightPlugin />
<MarkdownShortcutPlugin transformers={CHAT_TRANSFORMERS} />
<AutoSavePlugin onSave={save} />

<MemoryHighlightPlugin />

<MarketingBorder />
</div>
<MemoryPanelEnhanced onSaveComplete={saveImmediate} />
</div>
</LexicalComposer>
);
Expand Down
Loading