Add daily journal system (Milestone 5)

- Add journal service with date-based organization
- Add quick capture with timestamps and tags
- Add auto-linking for mentioned nodes and hashtags
- Add AI-powered daily summary generation
- Add journal search across all entries
- Add CLI commands: journal, j (alias), c (quick capture)
- Add MCP tools: memory_journal, memory_journal_list, memory_journal_summarize
This commit is contained in:
2026-02-03 10:57:23 +01:00
parent 056a02d936
commit 67b1e3b481
4 changed files with 446 additions and 0 deletions

160
src/cli/commands/journal.ts Normal file
View File

@@ -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 <date>', 'Specific date (YYYY-MM-DD)')
.option('--yesterday', 'Yesterday\'s journal')
.option('-l, --list', 'List recent journals')
.option('--month <month>', 'Filter by month (YYYY-MM)')
.option('-s, --search <query>', 'Search journals')
.option('--summarize', 'Generate AI summary')
.option('-t, --tags <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>', '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...>', 'Text to capture')
.option('-t, --tags <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}`);
});

View File

@@ -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();

222
src/core/journal.ts Normal file
View File

@@ -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<Node> {
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<void> {
// 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<Node[]> {
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<string | null> {
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 };

View File

@@ -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';