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:
160
src/cli/commands/journal.ts
Normal file
160
src/cli/commands/journal.ts
Normal 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}`);
|
||||
});
|
||||
@@ -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
222
src/core/journal.ts
Normal 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 };
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user