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 { captureCommand, captureHookCommand, configCommand } from './commands/capture';
|
||||||
import { contextCommand, contextHookCommand } from './commands/context';
|
import { contextCommand, contextHookCommand } from './commands/context';
|
||||||
import { indexCommand } from './commands/index-cmd';
|
import { indexCommand } from './commands/index-cmd';
|
||||||
|
import { journalCommand, journalAliasCommand, quickCaptureCommand } from './commands/journal';
|
||||||
import { closeDb } from '../core/db';
|
import { closeDb } from '../core/db';
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
@@ -46,6 +47,9 @@ program.addCommand(contextCommand);
|
|||||||
program.addCommand(contextHookCommand);
|
program.addCommand(contextHookCommand);
|
||||||
program.addCommand(configCommand);
|
program.addCommand(configCommand);
|
||||||
program.addCommand(indexCommand);
|
program.addCommand(indexCommand);
|
||||||
|
program.addCommand(journalCommand);
|
||||||
|
program.addCommand(journalAliasCommand);
|
||||||
|
program.addCommand(quickCaptureCommand);
|
||||||
|
|
||||||
program.hook('postAction', () => {
|
program.hook('postAction', () => {
|
||||||
closeDb();
|
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 ---
|
// --- memory_index ---
|
||||||
import { indexProject } from '../core/indexer';
|
import { indexProject } from '../core/indexer';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user