diff --git a/src/cli/commands/journal.ts b/src/cli/commands/journal.ts new file mode 100644 index 0000000..d835bfb --- /dev/null +++ b/src/cli/commands/journal.ts @@ -0,0 +1,160 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { + getOrCreateJournal, + appendToJournal, + listJournals, + searchJournals, + generateJournalSummary, + getJournalByDate, + formatDate, + getDateFromOffset, + JournalMetadata, +} from '../../core/journal'; + +export const journalCommand = new Command('journal') + .description('Daily journal - quick capture and daily notes') + .argument('[text...]', 'Text to add to today\'s journal') + .option('-d, --date ', 'Specific date (YYYY-MM-DD)') + .option('--yesterday', 'Yesterday\'s journal') + .option('-l, --list', 'List recent journals') + .option('--month ', 'Filter by month (YYYY-MM)') + .option('-s, --search ', 'Search journals') + .option('--summarize', 'Generate AI summary') + .option('-t, --tags ', 'Tags for the entry (comma-separated)') + .action(async (textParts: string[], opts) => { + try { + // Determine target date + let targetDate: string | undefined; + if (opts.date) { + targetDate = opts.date; + } else if (opts.yesterday) { + targetDate = getDateFromOffset('yesterday'); + } + + // List journals + if (opts.list) { + const journals = listJournals({ month: opts.month, limit: 20 }); + if (journals.length === 0) { + console.log(chalk.yellow('No journals found.')); + return; + } + console.log(chalk.cyan('Recent Journals:\n')); + for (const j of journals) { + const meta = j.metadata as JournalMetadata; + const entryCount = meta.entries?.length || 0; + const summaryIndicator = meta.summary ? ' [summarized]' : ''; + console.log(` ${chalk.white(meta.date)} ${chalk.dim(`${entryCount} entries`)}${chalk.green(summaryIndicator)}`); + } + return; + } + + // Search journals + if (opts.search) { + const results = await searchJournals(opts.search); + if (results.length === 0) { + console.log(chalk.yellow('No matching journals found.')); + return; + } + console.log(chalk.cyan(`Found ${results.length} journals:\n`)); + for (const j of results) { + const meta = j.metadata as JournalMetadata; + console.log(chalk.white(`${meta.date}:`)); + // Show matching entries + const entries = meta.entries?.filter(e => + e.text.toLowerCase().includes(opts.search.toLowerCase()) + ) || []; + for (const e of entries.slice(0, 3)) { + console.log(chalk.dim(` ${e.time}`) + ` ${e.text}`); + } + if (entries.length > 3) { + console.log(chalk.dim(` ... and ${entries.length - 3} more`)); + } + console.log(); + } + return; + } + + // Generate summary + if (opts.summarize) { + console.log(chalk.cyan('Generating summary...')); + const summary = await generateJournalSummary(targetDate); + if (!summary) { + console.log(chalk.yellow('No entries to summarize.')); + return; + } + console.log(); + console.log(chalk.green('Summary:')); + console.log(summary); + return; + } + + // Add entry or view journal + const text = textParts.join(' ').trim(); + + if (text) { + // Add entry + const tags = opts.tags?.split(',').map((t: string) => t.trim()); + const { journal, entry } = await appendToJournal(text, { tags, date: targetDate }); + const meta = journal.metadata as JournalMetadata; + console.log(chalk.green(`✓ Added to ${meta.date}`)); + console.log(chalk.dim(` ${entry.time}`) + ` ${text}`); + } else { + // View journal + const journal = await getOrCreateJournal(targetDate); + const meta = journal.metadata as JournalMetadata; + console.log(chalk.cyan(`Journal: ${meta.date}`)); + console.log(chalk.dim(`ID: ${journal.id.slice(0, 8)}`)); + console.log(); + + if (!meta.entries?.length) { + console.log(chalk.dim('No entries yet. Add one with: cortex journal "your text"')); + } else { + for (const entry of meta.entries) { + const tagStr = entry.tags?.length ? chalk.blue(` [${entry.tags.join(', ')}]`) : ''; + console.log(chalk.dim(`${entry.time}`) + tagStr + ` ${entry.text}`); + } + } + + if (meta.summary) { + console.log(); + console.log(chalk.green('Summary:')); + console.log(meta.summary); + } + } + } catch (err: any) { + console.error(chalk.red(`Error: ${err.message}`)); + process.exit(1); + } + }); + +// Alias: cortex j +export const journalAliasCommand = new Command('j') + .description('Alias for journal command') + .argument('[text...]', 'Text to add') + .option('-t, --tags ', 'Tags (comma-separated)') + .action(async (textParts: string[], opts) => { + const text = textParts.join(' ').trim(); + if (!text) { + const journal = await getOrCreateJournal(); + const meta = journal.metadata as JournalMetadata; + console.log(chalk.cyan(`Journal: ${meta.date} (${meta.entries?.length || 0} entries)`)); + return; + } + const tags = opts.tags?.split(',').map((t: string) => t.trim()); + const { journal, entry } = await appendToJournal(text, { tags }); + const meta = journal.metadata as JournalMetadata; + console.log(chalk.green(`✓ ${meta.date} ${entry.time}`) + ` ${text}`); + }); + +// Quick capture: cortex c +export const quickCaptureCommand = new Command('c') + .description('Quick capture to today\'s journal') + .argument('', 'Text to capture') + .option('-t, --tags ', 'Tags (comma-separated)') + .action(async (textParts: string[], opts) => { + const text = textParts.join(' ').trim(); + const tags = opts.tags?.split(',').map((t: string) => t.trim()); + const { entry } = await appendToJournal(text, { tags }); + console.log(chalk.green(`✓ ${entry.time}`) + ` ${text}`); + }); diff --git a/src/cli/index.ts b/src/cli/index.ts index 27602a8..a802482 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -17,6 +17,7 @@ import { restoreCommand } from './commands/restore'; import { captureCommand, captureHookCommand, configCommand } from './commands/capture'; import { contextCommand, contextHookCommand } from './commands/context'; import { indexCommand } from './commands/index-cmd'; +import { journalCommand, journalAliasCommand, quickCaptureCommand } from './commands/journal'; import { closeDb } from '../core/db'; const program = new Command(); @@ -46,6 +47,9 @@ program.addCommand(contextCommand); program.addCommand(contextHookCommand); program.addCommand(configCommand); program.addCommand(indexCommand); +program.addCommand(journalCommand); +program.addCommand(journalAliasCommand); +program.addCommand(quickCaptureCommand); program.hook('postAction', () => { closeDb(); diff --git a/src/core/journal.ts b/src/core/journal.ts new file mode 100644 index 0000000..8ed39b3 --- /dev/null +++ b/src/core/journal.ts @@ -0,0 +1,222 @@ +import { addNode, updateNode, listNodes, getNode, addEdge } from './store'; +import { Node } from '../types'; +import { generate, isGenAvailable } from './search/ollamaGen'; + +export interface JournalEntry { + time: string; + text: string; + tags?: string[]; +} + +export interface JournalMetadata { + date: string; + entries: JournalEntry[]; + summary?: string; +} + +function formatDate(date: Date): string { + return date.toISOString().split('T')[0]; +} + +function formatTime(date: Date): string { + return date.toTimeString().slice(0, 5); +} + +function dateTags(dateStr: string): string[] { + const [year, month] = dateStr.split('-'); + return [year, `${year}-${month}`]; +} + +function getDateFromOffset(offset: string): string { + const now = new Date(); + if (offset === 'yesterday') { + now.setDate(now.getDate() - 1); + } else if (offset === 'tomorrow') { + now.setDate(now.getDate() + 1); + } else if (offset.match(/^-\d+$/)) { + now.setDate(now.getDate() + parseInt(offset)); + } + return formatDate(now); +} + +export async function getOrCreateJournal(date?: string): Promise { + const targetDate = date || formatDate(new Date()); + const title = `Journal: ${targetDate}`; + + // Find existing journal for this date + const existing = listNodes({ kind: 'memory', tags: ['journal'] }) + .find(n => n.title === title); + + if (existing) { + return existing; + } + + // Create new journal + const dayName = new Date(targetDate).toLocaleDateString('en-US', { weekday: 'long' }); + return addNode({ + kind: 'memory', + title, + content: `# ${dayName}, ${targetDate}\n\n`, + tags: ['journal', 'daily', ...dateTags(targetDate)], + metadata: { + date: targetDate, + entries: [], + }, + }); +} + +export async function appendToJournal( + text: string, + options?: { tags?: string[]; date?: string } +): Promise<{ journal: Node; entry: JournalEntry }> { + const journal = await getOrCreateJournal(options?.date); + const time = formatTime(new Date()); + + const entry: JournalEntry = { + time, + text, + tags: options?.tags, + }; + + // Format entry line + const tagStr = options?.tags?.length ? ` \`${options.tags.join(', ')}\`` : ''; + const entryLine = `- **${time}**${tagStr} ${text}`; + + // Append to content + const newContent = journal.content.trimEnd() + '\n' + entryLine + '\n'; + + // Update metadata + const metadata = journal.metadata as JournalMetadata; + const entries = [...(metadata.entries || []), entry]; + + const updated = await updateNode(journal.id, { + content: newContent, + metadata: { ...metadata, entries }, + }); + + // Auto-link mentioned nodes (if text contains node IDs or @mentions) + await linkMentionedNodes(journal.id, text); + + return { journal: updated!, entry }; +} + +async function linkMentionedNodes(journalId: string, text: string): Promise { + // Find UUID-like patterns (node IDs) + const uuidPattern = /\b([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/gi; + const matches = text.match(uuidPattern); + + if (matches) { + for (const id of matches) { + const node = getNode(id); + if (node && node.id !== journalId) { + try { + addEdge(journalId, node.id, 'relates_to', { reason: 'mentioned' }); + } catch { + // Edge might already exist + } + } + } + } + + // Find #hashtag patterns + const hashtagPattern = /#(\w+)/g; + const hashtags = [...text.matchAll(hashtagPattern)].map(m => m[1]); + + if (hashtags.length) { + // Find nodes with these tags + const related = listNodes({ tags: hashtags, limit: 5 }); + for (const node of related) { + if (node.id !== journalId) { + try { + addEdge(journalId, node.id, 'relates_to', { reason: 'hashtag' }); + } catch { + // Edge might already exist + } + } + } + } +} + +export function listJournals(options?: { + limit?: number; + month?: string; + year?: string; +}): Node[] { + let tags = ['journal']; + if (options?.month) tags.push(options.month); + else if (options?.year) tags.push(options.year); + + return listNodes({ + kind: 'memory', + tags, + limit: options?.limit || 30, + }).sort((a, b) => { + // Sort by date descending + const dateA = (a.metadata as JournalMetadata)?.date || ''; + const dateB = (b.metadata as JournalMetadata)?.date || ''; + return dateB.localeCompare(dateA); + }); +} + +export async function searchJournals(query: string, limit: number = 10): Promise { + const journals = listNodes({ kind: 'memory', tags: ['journal'], limit: 100 }); + const lowerQuery = query.toLowerCase(); + + return journals + .filter(j => j.content.toLowerCase().includes(lowerQuery) || j.title.toLowerCase().includes(lowerQuery)) + .slice(0, limit); +} + +export async function generateJournalSummary(date?: string): Promise { + const journal = await getOrCreateJournal(date); + const metadata = journal.metadata as JournalMetadata; + + if (!metadata.entries?.length) { + return null; + } + + const available = await isGenAvailable(); + if (!available) { + // Fallback: simple summary + const count = metadata.entries.length; + return `${count} entries recorded.`; + } + + const entriesText = metadata.entries + .map(e => `- ${e.time}: ${e.text}`) + .join('\n'); + + const prompt = `Summarize this day's journal entries in 2-3 sentences. Focus on accomplishments, decisions, and key points. + +Journal entries: +${entriesText} + +Summary:`; + + const summary = await generate(prompt); + + if (summary) { + // Save summary to journal + await updateNode(journal.id, { + metadata: { ...metadata, summary }, + }); + } + + return summary; +} + +export function getJournalByDate(date: string): Node | null { + const title = `Journal: ${date}`; + return listNodes({ kind: 'memory', tags: ['journal'] }) + .find(n => n.title === title) || null; +} + +export function getTodayJournal(): Node | null { + return getJournalByDate(formatDate(new Date())); +} + +export function getYesterdayJournal(): Node | null { + return getJournalByDate(getDateFromOffset('yesterday')); +} + +export { formatDate, formatTime, getDateFromOffset }; diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 703c028..b1d2600 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -590,6 +590,66 @@ server.tool( } ); +// --- memory_journal --- +import { getOrCreateJournal, appendToJournal, listJournals, generateJournalSummary, JournalMetadata } from '../core/journal'; + +server.tool( + 'memory_journal', + 'Get or create today\'s journal, or add an entry to it', + { + text: z.string().optional().describe('Text to add to journal (if omitted, returns current journal)'), + date: z.string().optional().describe('Specific date (YYYY-MM-DD)'), + tags: z.array(z.string()).optional().describe('Tags for the entry'), + }, + async ({ text, date, tags }) => { + if (text) { + const { journal, entry } = await appendToJournal(text, { tags, date }); + const meta = journal.metadata as JournalMetadata; + return { content: [{ type: 'text' as const, text: serialize({ added: true, date: meta.date, entry }) }] }; + } + const journal = await getOrCreateJournal(date); + return { content: [{ type: 'text' as const, text: serialize(journal) }] }; + } +); + +server.tool( + 'memory_journal_list', + 'List recent journals', + { + limit: z.number().optional().describe('Max journals to return (default: 10)'), + month: z.string().optional().describe('Filter by month (YYYY-MM)'), + }, + async ({ limit, month }) => { + const journals = listJournals({ limit: limit || 10, month }); + return { + content: [{ + type: 'text' as const, + text: serialize(journals.map(j => { + const meta = j.metadata as JournalMetadata; + return { + id: j.id, + date: meta.date, + entries: meta.entries?.length || 0, + hasSummary: !!meta.summary, + }; + })), + }], + }; + } +); + +server.tool( + 'memory_journal_summarize', + 'Generate AI summary for a journal', + { + date: z.string().optional().describe('Date to summarize (default: today)'), + }, + async ({ date }) => { + const summary = await generateJournalSummary(date); + return { content: [{ type: 'text' as const, text: serialize({ summary }) }] }; + } +); + // --- memory_index --- import { indexProject } from '../core/indexer';