feat: dashboard Second Brain, essai 7 jours et vérification e-mail
Rendre le dashboard actionnable (inbox, peek, carte mentale), aligner la facturation sur l’essai 7 jours, et bloquer le login e-mail tant que l’adresse n’est pas confirmée. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -19,7 +19,7 @@ const DEFAULT_NOTES_LIMIT = 100;
|
||||
const MAX_NOTES_LIMIT = 500;
|
||||
|
||||
const NOTE_COLORS = 'default, red, orange, yellow, green, teal, blue, purple, pink, gray';
|
||||
const LABEL_COLORS = 'red, orange, yellow, green, teal, blue, purple, pink, gray';
|
||||
const LABEL_COLORS = ['red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'gray'];
|
||||
|
||||
export function parseNote(dbNote) {
|
||||
if (!dbNote) return null;
|
||||
@@ -463,6 +463,100 @@ const toolDefinitions = [
|
||||
description: 'Get notes with due reminders. Designed for cron/automation.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
},
|
||||
{
|
||||
name: 'get_upcoming_reminders',
|
||||
description: 'Get notes with reminders due in the next N hours (default 24).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
hours: { type: 'number', description: 'Number of hours ahead to look', default: 24 },
|
||||
includeDone: { type: 'boolean', description: 'Include already-done reminders', default: false },
|
||||
limit: { type: 'number', description: 'Max results', default: 100 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'update_reminder',
|
||||
description: 'Set, update, or clear a reminder on a note.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Note ID' },
|
||||
reminder: { type: 'string', description: 'ISO 8601 datetime, or null to clear', nullable: true },
|
||||
isReminderDone: { type: 'boolean' },
|
||||
reminderRecurrence: { type: 'string', description: 'daily, weekly, monthly, yearly', nullable: true },
|
||||
reminderLocation: { type: 'string', nullable: true },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
|
||||
// ═══ SEMANTIC / AI ═══
|
||||
{
|
||||
name: 'find_similar_notes',
|
||||
description: 'Find notes semantically similar to a given note using its vector embedding. Requires the note to have an embedding.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Source note ID' },
|
||||
limit: { type: 'number', description: 'Max results', default: 10 },
|
||||
threshold: { type: 'number', description: 'Minimum cosine similarity (0-1)', default: 0.7 },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_memory_echo_insights',
|
||||
description: 'List Memory Echo insights: AI-discovered connections between notes.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
includeViewed: { type: 'boolean', description: 'Include already viewed insights', default: false },
|
||||
includeDismissed: { type: 'boolean', description: 'Include dismissed insights', default: false },
|
||||
limit: { type: 'number', description: 'Max results', default: 20 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'dismiss_memory_echo_insight',
|
||||
description: 'Mark a Memory Echo insight as viewed or dismissed.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Insight ID' },
|
||||
viewed: { type: 'boolean', description: 'Mark as viewed', default: true },
|
||||
dismissed: { type: 'boolean', description: 'Mark as dismissed', default: true },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
|
||||
// ═══ FILTERS & STATISTICS ═══
|
||||
{
|
||||
name: 'get_notes_by_label',
|
||||
description: 'List notes that have a specific label/tag.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
label: { type: 'string', description: 'Label text to match' },
|
||||
notebookId: { type: 'string', description: 'Filter by notebook', nullable: true },
|
||||
includeArchived: { type: 'boolean', default: false },
|
||||
limit: { type: 'number', description: 'Max results', default: 100 },
|
||||
},
|
||||
required: ['label'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_note_statistics',
|
||||
description: 'Get statistics about notes, notebooks, labels, reminders, and trash.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
includeArchived: { type: 'boolean', description: 'Include archived notes in counts', default: false },
|
||||
includeTrashed: { type: 'boolean', description: 'Include trashed notes in counts', default: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Tool Handlers ──────────────────────────────────────────────────────────
|
||||
@@ -502,7 +596,7 @@ export function registerTools(server, prisma) {
|
||||
title: args.title || null,
|
||||
content: args.content,
|
||||
color: args.color || 'default',
|
||||
type: args.type || 'text',
|
||||
type: args.type || 'richtext',
|
||||
checkItems: args.checkItems ?? null,
|
||||
labels: args.labels ?? null,
|
||||
isPinned: args.isPinned || false,
|
||||
@@ -1003,6 +1097,201 @@ export function registerTools(server, prisma) {
|
||||
return textResult({ count: reminders.length, reminders });
|
||||
}
|
||||
|
||||
case 'get_upcoming_reminders': {
|
||||
const now = new Date();
|
||||
const horizon = new Date(now.getTime() + (args.hours || 24) * 60 * 60 * 1000);
|
||||
const extra = {
|
||||
reminder: { not: null, gte: now, lte: horizon },
|
||||
isArchived: false,
|
||||
};
|
||||
if (!args.includeDone) extra.isReminderDone = false;
|
||||
|
||||
const reminders = await prisma.note.findMany({
|
||||
where: noteWhere(uid, extra),
|
||||
select: { id: true, title: true, content: true, reminder: true, isReminderDone: true, notebookId: true },
|
||||
orderBy: { reminder: 'asc' },
|
||||
take: args.limit || 100,
|
||||
});
|
||||
|
||||
return textResult({ count: reminders.length, horizon, reminders });
|
||||
}
|
||||
|
||||
case 'update_reminder': {
|
||||
const d = { updatedAt: new Date() };
|
||||
if ('reminder' in args) d.reminder = args.reminder ? new Date(args.reminder) : null;
|
||||
if ('isReminderDone' in args) d.isReminderDone = args.isReminderDone;
|
||||
if ('reminderRecurrence' in args) d.reminderRecurrence = args.reminderRecurrence || null;
|
||||
if ('reminderLocation' in args) d.reminderLocation = args.reminderLocation || null;
|
||||
|
||||
const note = await prisma.note.update({
|
||||
where: { id: args.id, userId: uid, trashedAt: null },
|
||||
data: d,
|
||||
});
|
||||
return textResult(parseNote(note));
|
||||
}
|
||||
|
||||
// ═══ SEMANTIC / AI ═══
|
||||
case 'find_similar_notes': {
|
||||
const source = await prisma.note.findUnique({
|
||||
where: { id: args.id, userId: uid, trashedAt: null },
|
||||
select: { id: true, title: true },
|
||||
});
|
||||
if (!source) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
|
||||
|
||||
const sourceEmbedding = await prisma.noteEmbedding.findUnique({
|
||||
where: { noteId: args.id },
|
||||
select: { embedding: true },
|
||||
});
|
||||
if (!sourceEmbedding) {
|
||||
return textResult({
|
||||
noteId: args.id,
|
||||
error: 'Source note has no embedding. Index it first via the Memento app.',
|
||||
results: [],
|
||||
});
|
||||
}
|
||||
|
||||
const embeddingArray = Array.isArray(sourceEmbedding.embedding)
|
||||
? sourceEmbedding.embedding
|
||||
: String(sourceEmbedding.embedding).replace(/^\[|\]$/g, '').split(',').map(Number);
|
||||
const vecStr = `[${embeddingArray.join(',')}]`;
|
||||
const limit = Math.min(args.limit || 10, 50);
|
||||
const threshold = args.threshold ?? 0.7;
|
||||
|
||||
const rows = await prisma.$queryRawUnsafe(
|
||||
`SELECT n.id, n.title, n.content, n.color, n.type, n."isPinned", n."isArchived",
|
||||
n."notebookId", n."createdAt", n."updatedAt",
|
||||
1 - (e."embedding"::vector <=> $1::vector) AS similarity
|
||||
FROM "Note" n
|
||||
INNER JOIN "NoteEmbedding" e ON e."noteId" = n.id
|
||||
WHERE n."trashedAt" IS NULL
|
||||
AND n."isArchived" = false
|
||||
AND n."userId" = $2
|
||||
AND n.id != $3
|
||||
AND 1 - (e."embedding"::vector <=> $1::vector) >= $4
|
||||
ORDER BY e."embedding"::vector <=> $1::vector ASC
|
||||
LIMIT $5`,
|
||||
vecStr,
|
||||
uid,
|
||||
args.id,
|
||||
threshold,
|
||||
limit,
|
||||
);
|
||||
|
||||
return textResult({
|
||||
noteId: args.id,
|
||||
sourceTitle: source.title || 'Untitled',
|
||||
count: rows.length,
|
||||
results: rows.map(r => ({ ...parseNoteLightweight(r), similarity: Number(r.similarity) })),
|
||||
});
|
||||
}
|
||||
|
||||
case 'get_memory_echo_insights': {
|
||||
const where = { userId: uid };
|
||||
if (!args.includeViewed) where.viewed = false;
|
||||
if (!args.includeDismissed) where.dismissed = false;
|
||||
|
||||
const insights = await prisma.memoryEchoInsight.findMany({
|
||||
where,
|
||||
orderBy: { similarityScore: 'desc' },
|
||||
take: Math.min(args.limit || 20, 100),
|
||||
include: {
|
||||
note1: { select: { id: true, title: true } },
|
||||
note2: { select: { id: true, title: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return textResult({
|
||||
count: insights.length,
|
||||
insights: insights.map(i => ({
|
||||
id: i.id,
|
||||
insight: i.insight,
|
||||
similarityScore: i.similarityScore,
|
||||
insightDate: i.insightDate,
|
||||
viewed: i.viewed,
|
||||
dismissed: i.dismissed,
|
||||
note1: i.note1,
|
||||
note2: i.note2,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
case 'dismiss_memory_echo_insight': {
|
||||
const d = {};
|
||||
if ('viewed' in args) d.viewed = args.viewed;
|
||||
if ('dismissed' in args) d.dismissed = args.dismissed;
|
||||
|
||||
const insight = await prisma.memoryEchoInsight.update({
|
||||
where: { id: args.id, userId: uid },
|
||||
data: d,
|
||||
});
|
||||
return textResult({ success: true, id: insight.id, viewed: insight.viewed, dismissed: insight.dismissed });
|
||||
}
|
||||
|
||||
// ═══ FILTERS & STATISTICS ═══
|
||||
case 'get_notes_by_label': {
|
||||
const label = args.label.trim();
|
||||
const extra = {};
|
||||
if (!args.includeArchived) extra.isArchived = false;
|
||||
if (args.notebookId) {
|
||||
extra.notebookId = args.notebookId === 'inbox' ? null : args.notebookId;
|
||||
}
|
||||
|
||||
// Labels are stored as a JSON string like ["tag1","tag2"].
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
...noteWhere(uid, extra),
|
||||
labels: { contains: label },
|
||||
},
|
||||
orderBy: [{ isPinned: 'desc' }, { updatedAt: 'desc' }],
|
||||
take: Math.min(args.limit || 100, MAX_NOTES_LIMIT),
|
||||
});
|
||||
|
||||
return textResult({ label, count: notes.length, notes: notes.map(parseNoteLightweight) });
|
||||
}
|
||||
|
||||
case 'get_note_statistics': {
|
||||
const baseWhere = { userId: uid };
|
||||
const activeWhere = { ...baseWhere, trashedAt: null };
|
||||
|
||||
const [
|
||||
totalNotes,
|
||||
archivedNotes,
|
||||
trashedNotes,
|
||||
notebookCount,
|
||||
labelCount,
|
||||
notesWithReminders,
|
||||
notesWithImages,
|
||||
notesWithCheckItems,
|
||||
notesByType,
|
||||
notesByColor,
|
||||
] = await Promise.all([
|
||||
prisma.note.count({ where: activeWhere }),
|
||||
prisma.note.count({ where: { ...activeWhere, isArchived: true } }),
|
||||
prisma.note.count({ where: { ...baseWhere, trashedAt: { not: null } } }),
|
||||
prisma.notebook.count({ where: { userId: uid } }),
|
||||
prisma.label.count({ where: { notebook: { userId: uid } } }),
|
||||
prisma.note.count({ where: { ...activeWhere, reminder: { not: null } } }),
|
||||
prisma.note.count({ where: { ...activeWhere, images: { not: null } } }),
|
||||
prisma.note.count({ where: { ...activeWhere, checkItems: { not: null } } }),
|
||||
prisma.note.groupBy({ by: ['type'], where: activeWhere, _count: { type: true } }),
|
||||
prisma.note.groupBy({ by: ['color'], where: activeWhere, _count: { color: true } }),
|
||||
]);
|
||||
|
||||
return textResult({
|
||||
totalNotes,
|
||||
archivedNotes,
|
||||
trashedNotes,
|
||||
activeNotes: totalNotes - archivedNotes,
|
||||
notebookCount,
|
||||
labelCount,
|
||||
notesWithReminders,
|
||||
notesWithImages,
|
||||
notesWithCheckItems,
|
||||
byType: notesByType.map(t => ({ type: t.type, count: t._count.type })),
|
||||
byColor: notesByColor.map(c => ({ color: c.color, count: c._count.color })),
|
||||
});
|
||||
}
|
||||
|
||||
// ═══ TRASH ═══
|
||||
case 'trash_note': {
|
||||
const note = await prisma.note.update({
|
||||
|
||||
Reference in New Issue
Block a user