@notes-sync/service
Version:
Background service for AI-powered note synchronization
1,335 lines (1,141 loc) • 42.2 kB
text/typescript
import { getDailyTemplate } from './config/daily-template.config';
import path from 'path';
import fs from 'fs';
import { Logger } from './logger';
import { AIService } from './ai/ai-service';
import {
AIQueryRequest,
AIQueryResponse,
ViewNotesRequest,
ViewNotesResponse,
} from '@notes-sync/shared';
// Section constants for parsing
const TODAY_SECTION = `**Today's Focus**`;
const NOTE_SECTION = `**Notes**`;
const DONE_SECTION = `**Done**`;
const TOMORROW_SECTION = `**Tomorrow**`;
export class NoteInteractor {
constructor(
private notesDir: string,
private noteFile: string,
private aiService?: AIService
) {}
async writeNewDay(): Promise<void> {
try {
let quote = 'Progress over perfection';
let author = 'Unknown';
// Try to generate AI quote if available
if (this.aiService?.isDailyQuotesEnabled()) {
try {
// Get context from recent notes for better quote generation
const context = this.getRecentNotesContext();
const aiQuote =
await this.aiService.generateQuoteWithFallback(context);
if (aiQuote) {
quote = aiQuote.quote;
author = aiQuote.author;
Logger.log(`Generated AI quote: "${quote}" - ${author}`);
}
} catch (error) {
Logger.error(
`Failed to generate AI quote, using fallback: ${(error as Error).message}`
);
// Continue with default quote - don't let AI failures break daily creation
}
}
const template = getDailyTemplate(
new Date().toLocaleDateString(),
quote,
author
);
this.append('\n\n' + template);
Logger.log(
`Daily section created for ${new Date().toLocaleDateString()}`
);
} catch (error) {
Logger.error(
`Failed to write daily section: ${(error as Error).message}`
);
}
}
// Get recent notes context for AI quote generation
private getRecentNotesContext(): string {
try {
const recentDays = this.getPreviousDays(3); // Get last 3 days for context
const context = recentDays
.map(day => this.getNotes(day))
.filter(notes => notes.trim().length > 0)
.join(' ')
.substring(0, 300); // Limit context length
return context;
} catch (error) {
Logger.error(
`Failed to get recent notes context: ${(error as Error).message}`
);
return '';
}
}
append(text: string) {
fs.writeFileSync(path.join(this.notesDir, this.noteFile), text, {
flag: 'a',
});
}
// Helper method to read the full notes file
private readNotesFile(): string {
const filePath = path.join(this.notesDir, this.noteFile);
if (!fs.existsSync(filePath)) {
return '';
}
return fs.readFileSync(filePath, 'utf8');
}
// Helper method to parse days from the notes file
private parseDays(content: string): Map<string, string> {
const days = new Map<string, string>();
// Split by headers that start with #
const dayBlocks = content.split(/^# /m).filter(block => block.trim());
for (const block of dayBlocks) {
const lines = block.split('\n');
const dateMatch = lines[0].match(/^(.+?)$/);
if (dateMatch) {
const dateStr = dateMatch[1].trim();
const dayContent = '# ' + block;
days.set(dateStr, dayContent);
}
}
return days;
}
// Helper method to extract section content
private extractSection(
text: string,
sectionHeader: string,
nextSectionHeader?: string
): string {
const sectionIndex = text.indexOf(sectionHeader);
if (sectionIndex === -1) return '';
const startIndex = sectionIndex + sectionHeader.length;
let endIndex = text.length;
if (nextSectionHeader) {
const nextIndex = text.indexOf(nextSectionHeader, startIndex);
if (nextIndex !== -1) {
endIndex = nextIndex;
}
}
return text.substring(startIndex, endIndex).trim();
}
// Helper method to find today's date section in the file
private getTodaySection(): {
content: string;
startPos: number;
endPos: number;
} | null {
const content = this.readNotesFile();
const todayDate = new Date().toLocaleDateString();
const todayHeaderRegex = new RegExp(
`^# ${todayDate.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`,
'm'
);
const todayMatch = content.match(todayHeaderRegex);
if (!todayMatch) return null;
const startPos = todayMatch.index!;
// Find the next day section or end of file
const nextDayRegex = /^# \d+\/\d+\/\d+/m;
const remainingContent = content.substring(startPos + todayMatch[0].length);
const nextDayMatch = remainingContent.match(nextDayRegex);
const endPos = nextDayMatch
? startPos + todayMatch[0].length + nextDayMatch.index!
: content.length;
const dayContent = content.substring(startPos, endPos);
return { content: dayContent, startPos, endPos };
}
getPreviousDays(days: number): string[] {
try {
const content = this.readNotesFile();
const dayMap = this.parseDays(content);
// Get dates and sort them in descending order (most recent first)
const sortedDates = Array.from(dayMap.keys()).sort((a, b) => {
const dateA = new Date(a);
const dateB = new Date(b);
return dateB.getTime() - dateA.getTime();
});
// Take the requested number of days (excluding today)
const today = new Date().toLocaleDateString();
const previousDates = sortedDates
.filter(date => date !== today)
.slice(0, days);
return previousDates.map(date => dayMap.get(date) || '');
} catch (error) {
Logger.error(`Failed to get previous days: ${(error as Error).message}`);
return [];
}
}
getDate(date: Date): string {
try {
const content = this.readNotesFile();
const dayMap = this.parseDays(content);
const dateStr = date.toLocaleDateString();
return dayMap.get(dateStr) || '';
} catch (error) {
Logger.error(
`Failed to get date ${date.toLocaleDateString()}: ${(error as Error).message}`
);
return '';
}
}
getTodos(dayText: string): string[] {
try {
const todosSection = this.extractSection(
dayText,
TODAY_SECTION,
NOTE_SECTION
);
// Extract checkbox items (- [ ] or - [x])
const todoRegex = /^- \[[ x]\] (.+)$/gm;
const todos: string[] = [];
let match;
while ((match = todoRegex.exec(todosSection)) !== null) {
todos.push(match[1].trim());
}
return todos;
} catch (error) {
Logger.error(`Failed to extract todos: ${(error as Error).message}`);
return [];
}
}
getNotes(dayText: string): string {
try {
return this.extractSection(dayText, NOTE_SECTION, DONE_SECTION);
} catch (error) {
Logger.error(`Failed to extract notes: ${(error as Error).message}`);
return '';
}
}
async addTodo(text: string): Promise<void> {
try {
const todaySection = this.getTodaySection();
if (!todaySection) {
Logger.error("Today's section not found. Creating new day first.");
await this.writeNewDay();
return this.addTodo(text);
}
const content = this.readNotesFile();
const todayFocusStart = todaySection.content.indexOf(TODAY_SECTION);
if (todayFocusStart === -1) {
Logger.error("Today's Focus section not found");
return;
}
// Find the end of the Today's Focus section
const notesSectionStart = todaySection.content.indexOf(
NOTE_SECTION,
todayFocusStart
);
if (notesSectionStart === -1) {
Logger.error('Notes section not found');
return;
}
// Find the last todo item in the Today's Focus section
const focusSection = todaySection.content.substring(
todayFocusStart,
notesSectionStart
);
const todoRegex = /^- \[[ x]\] .+$/gm;
let lastTodoEnd = todayFocusStart + TODAY_SECTION.length;
let match;
while ((match = todoRegex.exec(focusSection)) !== null) {
lastTodoEnd = todayFocusStart + match.index! + match[0].length;
}
// Insert the new todo
const newTodo = `\n- [ ] ${text}`;
const insertPosition = todaySection.startPos + lastTodoEnd;
const newContent =
content.substring(0, insertPosition) +
newTodo +
content.substring(insertPosition);
fs.writeFileSync(path.join(this.notesDir, this.noteFile), newContent);
Logger.log(`Added todo: ${text}`);
} catch (error) {
Logger.error(`Failed to add todo: ${(error as Error).message}`);
}
}
async addNote(text: string): Promise<void> {
try {
const todaySection = this.getTodaySection();
if (!todaySection) {
Logger.error("Today's section not found. Creating new day first.");
await this.writeNewDay();
return this.addNote(text);
}
const content = this.readNotesFile();
const notesSectionStart = todaySection.content.indexOf(NOTE_SECTION);
if (notesSectionStart === -1) {
Logger.error('Notes section not found');
return;
}
// Find the end of the Notes section (before Done section)
const doneSectionStart = todaySection.content.indexOf(
DONE_SECTION,
notesSectionStart
);
if (doneSectionStart === -1) {
Logger.error('Done section not found');
return;
}
// Find where to insert the note (at the end of the Notes section)
const notesSection = todaySection.content.substring(
notesSectionStart + NOTE_SECTION.length,
doneSectionStart
);
// Find the last non-empty line in the notes section
const noteLines = notesSection.split('\n');
let lastContentIndex = -1;
for (let i = noteLines.length - 1; i >= 0; i--) {
if (noteLines[i].trim()) {
lastContentIndex = i;
break;
}
}
// Calculate insertion position
const basePosition =
todaySection.startPos + notesSectionStart + NOTE_SECTION.length;
let insertPosition;
let newNoteText;
if (lastContentIndex === -1) {
// No existing notes, insert right after the header
insertPosition = basePosition;
newNoteText = `\n\n${text}`;
} else {
// Insert after existing notes
const contentUpToLastLine = noteLines
.slice(0, lastContentIndex + 1)
.join('\n');
insertPosition = basePosition + contentUpToLastLine.length;
newNoteText = `\n\n${text}`;
}
const newContent =
content.substring(0, insertPosition) +
newNoteText +
content.substring(insertPosition);
fs.writeFileSync(path.join(this.notesDir, this.noteFile), newContent);
Logger.log(`Added note: ${text.substring(0, 50)}...`);
} catch (error) {
Logger.error(`Failed to add note: ${(error as Error).message}`);
}
}
markTodoComplete(todoText: string): boolean {
try {
const content = this.readNotesFile();
const todoPattern = new RegExp(
`^(- \\[ \\] )(${todoText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})$`,
'gm'
);
if (!todoPattern.test(content)) {
Logger.error(`Todo not found: ${todoText}`);
return false;
}
const updatedContent = content.replace(todoPattern, '- [x] $2');
fs.writeFileSync(path.join(this.notesDir, this.noteFile), updatedContent);
Logger.log(`Marked todo complete: ${todoText}`);
return true;
} catch (error) {
Logger.error(`Failed to mark todo complete: ${(error as Error).message}`);
return false;
}
}
deleteTodo(todoText: string): boolean {
try {
const content = this.readNotesFile();
// Match both incomplete and complete todos
const todoPattern = new RegExp(
`^- \\[[x ]\\] ${todoText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`,
'gm'
);
if (!todoPattern.test(content)) {
Logger.error(`Todo not found: ${todoText}`);
return false;
}
// Remove the entire line including newlines
const updatedContent = content.replace(todoPattern, '');
// Clean up any double newlines that might result
const cleanedContent = updatedContent.replace(/\n\n\n+/g, '\n\n');
fs.writeFileSync(path.join(this.notesDir, this.noteFile), cleanedContent);
Logger.log(`Deleted todo: ${todoText}`);
return true;
} catch (error) {
Logger.error(`Failed to delete todo: ${(error as Error).message}`);
return false;
}
}
searchNotes(
query: string,
daysBack: number = 30
): Array<{ date: string; context: string }> {
try {
const content = this.readNotesFile();
const dayMap = this.parseDays(content);
const results: Array<{ date: string; context: string }> = [];
// Get dates and sort them in descending order
const sortedDates = Array.from(dayMap.keys()).sort((a, b) => {
const dateA = new Date(a);
const dateB = new Date(b);
return dateB.getTime() - dateA.getTime();
});
// Search through the specified number of days
const searchDates = sortedDates.slice(0, daysBack);
const searchRegex = new RegExp(
query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
'gi'
);
for (const date of searchDates) {
const dayContent = dayMap.get(date) || '';
const lines = dayContent.split('\n');
for (let i = 0; i < lines.length; i++) {
if (searchRegex.test(lines[i])) {
// Get context: 1 line before and after the match
const contextStart = Math.max(0, i - 1);
const contextEnd = Math.min(lines.length, i + 2);
const context = lines.slice(contextStart, contextEnd).join('\n');
results.push({
date,
context: context.trim(),
});
}
}
}
Logger.log(`Search for "${query}" found ${results.length} results`);
return results;
} catch (error) {
Logger.error(`Failed to search notes: ${(error as Error).message}`);
return [];
}
}
getIncompleteTodos(
daysBack: number = 7
): Array<{ date: string; todo: string }> {
try {
const content = this.readNotesFile();
const dayMap = this.parseDays(content);
const incompleteTodos: Array<{ date: string; todo: string }> = [];
// Get dates and sort them in descending order
const sortedDates = Array.from(dayMap.keys()).sort((a, b) => {
const dateA = new Date(a);
const dateB = new Date(b);
return dateB.getTime() - dateA.getTime();
});
// Check the specified number of days
const checkDates = sortedDates.slice(0, daysBack);
for (const date of checkDates) {
const dayContent = dayMap.get(date) || '';
const todos = this.getTodos(dayContent);
// Get incomplete todos (those that start with [ ])
const incompleteRegex = /^- \[ \] (.+)$/gm;
const todayFocusSection = this.extractSection(
dayContent,
TODAY_SECTION,
NOTE_SECTION
);
let match;
while ((match = incompleteRegex.exec(todayFocusSection)) !== null) {
incompleteTodos.push({
date,
todo: match[1].trim(),
});
}
}
Logger.log(
`Found ${incompleteTodos.length} incomplete todos from last ${daysBack} days`
);
return incompleteTodos;
} catch (error) {
Logger.error(
`Failed to get incomplete todos: ${(error as Error).message}`
);
return [];
}
}
archiveCompletedTodos(): number {
try {
const todaySection = this.getTodaySection();
if (!todaySection) {
Logger.error("Today's section not found");
return 0;
}
const content = this.readNotesFile();
const todayFocusSection = this.extractSection(
todaySection.content,
TODAY_SECTION,
NOTE_SECTION
);
const doneSection = this.extractSection(
todaySection.content,
DONE_SECTION,
TOMORROW_SECTION
);
// Find completed todos in Today's Focus
const completedTodoRegex = /^- \[x\] (.+)$/gm;
const completedTodos: string[] = [];
let match;
while ((match = completedTodoRegex.exec(todayFocusSection)) !== null) {
completedTodos.push(match[1].trim());
}
if (completedTodos.length === 0) {
Logger.log('No completed todos to archive');
return 0;
}
// Remove completed todos from Today's Focus section
let updatedTodayFocus = todayFocusSection;
for (const todo of completedTodos) {
const todoPattern = new RegExp(
`^- \\[x\\] ${todo.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`,
'gm'
);
updatedTodayFocus = updatedTodayFocus.replace(todoPattern, '');
}
// Clean up extra newlines
updatedTodayFocus = updatedTodayFocus.replace(/\n\n+/g, '\n\n').trim();
// Add completed todos to Done section
const archivedTodos = completedTodos.map(todo => `- ${todo}`).join('\n');
const updatedDoneSection = doneSection
? `${doneSection}\n${archivedTodos}`
: archivedTodos;
// Rebuild the day's content
const todayFocusStart = todaySection.content.indexOf(TODAY_SECTION);
const notesSectionStart = todaySection.content.indexOf(NOTE_SECTION);
const doneSectionStart = todaySection.content.indexOf(DONE_SECTION);
const tomorrowSectionStart =
todaySection.content.indexOf(TOMORROW_SECTION);
let newDayContent = todaySection.content.substring(
0,
todayFocusStart + TODAY_SECTION.length
);
newDayContent += `\n\n${updatedTodayFocus}\n\n`;
newDayContent += todaySection.content.substring(
notesSectionStart,
doneSectionStart + DONE_SECTION.length
);
newDayContent += `\n\n${updatedDoneSection}\n\n`;
newDayContent += todaySection.content.substring(tomorrowSectionStart);
// Replace the day's content in the full file
const newContent =
content.substring(0, todaySection.startPos) +
newDayContent +
content.substring(todaySection.endPos);
fs.writeFileSync(path.join(this.notesDir, this.noteFile), newContent);
Logger.log(`Archived ${completedTodos.length} completed todos`);
return completedTodos.length;
} catch (error) {
Logger.error(
`Failed to archive completed todos: ${(error as Error).message}`
);
return 0;
}
}
formatDocument(): { formatted: boolean; changesMade: string[] } {
try {
const content = this.readNotesFile();
if (!content.trim()) {
Logger.log('No content to format');
return { formatted: false, changesMade: [] };
}
const changes: string[] = [];
let formattedContent = content;
// 1. Remove trailing whitespace from all lines
const beforeTrailing = formattedContent;
formattedContent = formattedContent.replace(/[ \t]+$/gm, '');
if (beforeTrailing !== formattedContent) {
changes.push('Removed trailing whitespace');
}
// 2. Normalize multiple consecutive blank lines (max 2 blank lines) but preserve content integrity
const beforeBlankLines = formattedContent;
formattedContent = formattedContent.replace(/\n{4,}/g, '\n\n\n');
if (beforeBlankLines !== formattedContent) {
changes.push('Normalized excessive blank lines');
}
// 2.5. Fix any accidentally broken quotes and dates
const beforeFixes = formattedContent;
// Fix broken quotes (rejoin lines that should be together without adding extra spaces)
formattedContent = formattedContent.replace(
/(_[^_\n]*)\n([^_\n]*_)/g,
'$1$2'
);
// Fix broken dates (rejoin date parts)
formattedContent = formattedContent.replace(
/(# \d{1,2}\/\d{1,2}\/\d{2,3})\n(\d)/gm,
'$1$2'
);
if (beforeFixes !== formattedContent) {
changes.push('Fixed broken content lines');
}
// 3. Ensure consistent spacing around headers (but don't break dates)
const beforeHeaders = formattedContent;
// Add blank line before headers (except at start of file) - but only for complete date lines
formattedContent = formattedContent.replace(
/(?<!^)(?<!\n\n)(\n)(# \d{1,2}\/\d{1,2}\/\d{4})/gm,
'\n\n$2'
);
// Ensure blank line after complete date headers
formattedContent = formattedContent.replace(
/(^# \d{1,2}\/\d{1,2}\/\d{4})(?!\n\n)/gm,
'$1\n'
);
if (beforeHeaders !== formattedContent) {
changes.push('Standardized header spacing');
}
// 4. Standardize todo formatting (ensure space after checkbox)
const beforeTodos = formattedContent;
formattedContent = formattedContent.replace(
/^-\s*\[([x ])\]([^ ])/gm,
'- [$1] $2'
);
formattedContent = formattedContent.replace(
/^-\s*\[([x ])\]\s+/gm,
'- [$1] '
);
if (beforeTodos !== formattedContent) {
changes.push('Standardized todo formatting');
}
// 5. Ensure consistent bullet point formatting
const beforeBullets = formattedContent;
formattedContent = formattedContent.replace(/^-([^ ])/gm, '- $1');
formattedContent = formattedContent.replace(/^-\s{2,}/gm, '- ');
if (beforeBullets !== formattedContent) {
changes.push('Standardized bullet points');
}
// 6. Standardize section spacing (ensure double line break after section headers)
const beforeSections = formattedContent;
const sectionHeaders = [
TODAY_SECTION,
NOTE_SECTION,
DONE_SECTION,
TOMORROW_SECTION,
];
for (const section of sectionHeaders) {
const sectionRegex = new RegExp(
`(\\*\\*${section.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\*\\*)(?!\\n\\n)`,
'g'
);
formattedContent = formattedContent.replace(sectionRegex, '$1\n');
}
if (beforeSections !== formattedContent) {
changes.push('Standardized section spacing');
}
// 7. Clean up quote formatting (ensure proper spacing around quotes, but don't break quote content)
const beforeQuotes = formattedContent;
// Fix malformed quotes (remove space after opening underscore)
formattedContent = formattedContent.replace(/(_ )([^_]*_)/g, '_$2');
// Only add spacing after quotes that are followed by section headers or other content, but preserve quote integrity
formattedContent = formattedContent.replace(
/(_[^_\n]+_)(\s*)(\*\*)/gm,
'$1\n\n$3'
);
if (beforeQuotes !== formattedContent) {
changes.push('Cleaned quote formatting');
}
// 8. Ensure file ends with single newline
const beforeEnding = formattedContent;
formattedContent = formattedContent.replace(/\n*$/, '\n');
if (beforeEnding !== formattedContent) {
changes.push('Normalized file ending');
}
// 9. Remove empty todo items and clean up orphaned formatting
const beforeEmpty = formattedContent;
formattedContent = formattedContent.replace(/^- \[ \]\s*$/gm, '');
formattedContent = formattedContent.replace(/^- \[x\]\s*$/gm, '');
if (beforeEmpty !== formattedContent) {
changes.push('Removed empty todo items');
}
// 10. Final cleanup - remove any triple+ newlines and fix any remaining broken content
formattedContent = formattedContent.replace(/\n{4,}/g, '\n\n\n');
// Final pass: ensure quotes and dates weren't accidentally broken by any of the above rules
formattedContent = formattedContent.replace(
/(_[^_\n]*)\n([^_\n]*_)/g,
'$1$2'
);
formattedContent = formattedContent.replace(
/(# \d{1,2}\/\d{1,2}\/\d{2,3})\n(\d)/gm,
'$1$2'
);
if (changes.length > 0) {
fs.writeFileSync(
path.join(this.notesDir, this.noteFile),
formattedContent
);
Logger.log(
`Document formatted with ${changes.length} changes: ${changes.join(', ')}`
);
return { formatted: true, changesMade: changes };
} else {
Logger.log('Document already properly formatted');
return { formatted: false, changesMade: [] };
}
} catch (error) {
Logger.error(`Failed to format document: ${(error as Error).message}`);
return { formatted: false, changesMade: [] };
}
}
// Advanced formatter for specific cleanup scenarios
formatSection(sectionName: string): boolean {
try {
const todaySection = this.getTodaySection();
if (!todaySection) {
Logger.error("Today's section not found");
return false;
}
const content = this.readNotesFile();
let updatedSection = todaySection.content;
// Format specific section based on name
switch (sectionName) {
case 'todos':
// Clean up Today's Focus section
const focusSection = this.extractSection(
updatedSection,
TODAY_SECTION,
NOTE_SECTION
);
let cleanFocus = focusSection
.replace(/^- \[ \]\s*$/gm, '') // Remove empty todos
.replace(/^-\s*\[([x ])\]\s+/gm, '- [$1] ') // Standardize formatting
.replace(/\n{3,}/g, '\n\n'); // Remove excessive blank lines
// Replace the section
updatedSection = updatedSection.replace(
new RegExp(
`(${TODAY_SECTION.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})([\\s\\S]*?)(?=${NOTE_SECTION.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`
),
`$1\n\n${cleanFocus.trim()}\n\n`
);
break;
case 'notes':
// Clean up Notes section
const notesSection = this.extractSection(
updatedSection,
NOTE_SECTION,
DONE_SECTION
);
let cleanNotes = notesSection
.replace(/[ \t]+$/gm, '') // Remove trailing whitespace
.replace(/\n{4,}/g, '\n\n\n') // Limit consecutive blank lines
.trim();
// Replace the section
updatedSection = updatedSection.replace(
new RegExp(
`(${NOTE_SECTION.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})([\\s\\S]*?)(?=${DONE_SECTION.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`
),
`$1\n\n${cleanNotes}\n\n`
);
break;
default:
Logger.error(`Unknown section: ${sectionName}`);
return false;
}
// Replace the day's content in the full file
const newContent =
content.substring(0, todaySection.startPos) +
updatedSection +
content.substring(todaySection.endPos);
fs.writeFileSync(path.join(this.notesDir, this.noteFile), newContent);
Logger.log(`Formatted ${sectionName} section`);
return true;
} catch (error) {
Logger.error(
`Failed to format section ${sectionName}: ${(error as Error).message}`
);
return false;
}
}
// Validation helper to check common formatting issues
validateFormatting(): { isValid: boolean; issues: string[] } {
try {
const content = this.readNotesFile();
const issues: string[] = [];
// Check for broken dates
const brokenDatePattern = /(# \d{1,2}\/\d{1,2}\/\d{2,3})\n(\d)/gm;
if (brokenDatePattern.test(content)) {
issues.push('Found broken date headers');
}
// Check for broken quotes
const brokenQuotePattern = /(_[^_\n]*)\n([^_\n]*_)/g;
if (brokenQuotePattern.test(content)) {
issues.push('Found broken quote formatting');
}
// Check for malformed quotes (space after opening underscore)
const malformedQuotePattern = /(_ [^_]*_)/g;
if (malformedQuotePattern.test(content)) {
issues.push('Found quotes with incorrect spacing');
}
// Check for excessive blank lines
if (/\n{4,}/.test(content)) {
issues.push('Found excessive blank lines (4+)');
}
// Check for trailing whitespace
if (/[ \t]+$/m.test(content)) {
issues.push('Found trailing whitespace');
}
// Check for inconsistent todo formatting
if (/^-\s*\[([x ])\]([^ ])/m.test(content)) {
issues.push('Found inconsistent todo formatting');
}
Logger.log(`Formatting validation: ${issues.length} issues found`);
return { isValid: issues.length === 0, issues };
} catch (error) {
Logger.error(
`Failed to validate formatting: ${(error as Error).message}`
);
return { isValid: false, issues: ['Validation failed'] };
}
}
// Check if today's section already exists
hasTodaySection(): boolean {
return this.getTodaySection() !== null;
}
// Check if today's section is missing (only creates today, never backfills gaps)
checkForMissingDays(): string[] {
try {
const content = this.readNotesFile();
const dayMap = this.parseDays(content);
if (dayMap.size === 0) {
// No days exist, we definitely need today
return [new Date().toLocaleDateString()];
}
const missingDays: string[] = [];
// Only check if today is missing - we don't backfill gaps between days
if (!this.hasTodaySection()) {
missingDays.push(new Date().toLocaleDateString());
}
return missingDays;
} catch (error) {
Logger.error(
`Failed to check for missing days: ${(error as Error).message}`
);
return [];
}
}
// Auto-create daily section if needed (enhanced version)
async autoCreateDailySection(
force: boolean = false
): Promise<{ created: boolean; reason: string }> {
try {
// Check if today already exists (unless forced)
if (!force && this.hasTodaySection()) {
return { created: false, reason: "Today's section already exists" };
}
// Check for missing days
const missingDays = this.checkForMissingDays();
if (missingDays.length === 0 && !force) {
return { created: false, reason: 'No missing days detected' };
}
// Create today's section (or force create)
const today = new Date().toLocaleDateString();
if (missingDays.includes(today) || force) {
await this.writeNewDay();
Logger.log(`Auto-created daily section for ${today}`);
return { created: true, reason: `Created section for ${today}` };
}
return { created: false, reason: "Today's section not needed" };
} catch (error) {
Logger.error(
`Failed to auto-create daily section: ${(error as Error).message}`
);
return { created: false, reason: `Error: ${(error as Error).message}` };
}
}
// Get time since last entry (useful for wake detection logic)
getTimeSinceLastEntry(): number {
try {
const content = this.readNotesFile();
const dayMap = this.parseDays(content);
if (dayMap.size === 0) {
return Infinity; // No entries, very long time
}
const sortedDates = Array.from(dayMap.keys()).sort((a, b) => {
const dateA = new Date(a);
const dateB = new Date(b);
return dateB.getTime() - dateA.getTime();
});
const lastDate = new Date(sortedDates[0]);
const now = new Date();
return now.getTime() - lastDate.getTime();
} catch (error) {
Logger.error(
`Failed to get time since last entry: ${(error as Error).message}`
);
return 0;
}
}
// AI Query Processing
async processAIQuery(request: AIQueryRequest): Promise<AIQueryResponse> {
// Extract FULL daily sections with smart limiting
const { content, metadata } = this.extractFullDayContext(request.timeRange);
Logger.log(
`AI Query context: ${metadata.daysCovered} days, ${metadata.charactersUsed} chars${metadata.truncated ? ' (truncated)' : ''}`
);
// Debug: Log first 100 chars of content to see what's being sent
const contentPreview =
content.length > 100 ? content.substring(0, 100) + '...' : content;
Logger.log(`Content preview: "${contentPreview}"`);
// Simple, concise prompt
const prompt = `You are analyzing someone's personal daily notes to help answer their question: "${request.query}"
Please provide a concise, actionable response (2 paragraphs max). Prefer one to two sentences if it's a simple question.
${content}`;
if (!this.aiService?.isEnabled()) {
throw new Error('AI service is not available');
}
const response = await this.aiService.processQuery(prompt);
const actionItems = this.extractActionableItems(response);
return {
response,
contextUsed: metadata,
...(actionItems.length > 0 && { suggestions: actionItems }),
};
}
private extractFullDayContext(timeRange: AIQueryRequest['timeRange']): {
content: string;
metadata: {
daysCovered: number;
charactersUsed: number;
truncated: boolean;
};
} {
const DAILY_USE_LIMITS = {
DEFAULT_DAYS: 3, // Good recent context
MAX_DAYS: 14, // 2 weeks maximum
MAX_CHARACTERS: 15000, // ~4000 tokens, reasonable for Gemini
PRIORITY_RECENT_DAYS: 2, // Always include last 2 days fully
};
let targetDays = 3; // Default
switch (timeRange.type) {
case 'today':
targetDays = 1;
break;
case 'week':
targetDays = 7;
break;
case 'month':
targetDays = Math.min(14, 30);
break; // Cap at 2 weeks
case 'custom':
targetDays = Math.min(timeRange.days || 3, 14);
break;
}
try {
// Read notes file and parse days to get both dates and content
const notesFileContent = this.readNotesFile();
const dayMap = this.parseDays(notesFileContent);
Logger.log(`Found ${dayMap.size} days in notes for AI context`);
if (dayMap.size === 0) {
return {
content: `No daily notes found for the requested time range.
To get started:
1. Create a daily section: notes-sync daily --create
2. Add some notes: notes-sync add -n "your note"
3. Add some tasks: notes-sync add -t "your task"
Then try your AI query again!`,
metadata: {
daysCovered: 0,
charactersUsed: 0,
truncated: false,
},
};
}
// Get dates and sort them in descending order (most recent first)
const sortedDates = Array.from(dayMap.keys()).sort((a, b) => {
const dateA = new Date(a);
const dateB = new Date(b);
return dateB.getTime() - dateA.getTime();
});
// Take the requested number of days, prioritizing recent ones
const selectedDates = sortedDates.slice(0, targetDays);
const sections: string[] = [];
let totalCharacters = 0;
let truncated = false;
let daysIncluded = 0;
for (const date of selectedDates) {
const fullDayContent = dayMap.get(date) || '';
// Skip days with no meaningful content
if (!fullDayContent.trim()) {
continue;
}
const daySection = `## ${date}\n\n${fullDayContent.trim()}`;
const dayLength = daySection.length;
// Always include first 2 days (most recent)
if (daysIncluded < DAILY_USE_LIMITS.PRIORITY_RECENT_DAYS) {
sections.push(daySection);
totalCharacters += dayLength;
daysIncluded++;
continue;
}
// For remaining days, check if we can fit them
if (totalCharacters + dayLength <= DAILY_USE_LIMITS.MAX_CHARACTERS) {
sections.push(daySection);
totalCharacters += dayLength;
daysIncluded++;
} else {
// Try to include a truncated version
const availableSpace =
DAILY_USE_LIMITS.MAX_CHARACTERS - totalCharacters - 100; // Leave buffer
if (availableSpace > 500) {
// Only truncate if we have reasonable space
const truncatedDay = this.truncateDayContent(
daySection,
availableSpace
);
sections.push(truncatedDay);
totalCharacters += truncatedDay.length;
daysIncluded++;
truncated = true;
}
break; // Stop processing older days
}
}
const content = sections.join('\n\n---\n\n');
if (!content.trim()) {
return {
content: `Your daily sections exist but appear to be empty.
Try adding some content first:
- Add a note: notes-sync add -n "Had a productive meeting with the team"
- Add a task: notes-sync add -t "Review quarterly budget"
- Check your current status: notes-sync daily --status
Once you have some content, AI can analyze your notes and provide insights!`,
metadata: {
daysCovered: daysIncluded,
charactersUsed: 0,
truncated: false,
},
};
}
return {
content,
metadata: {
daysCovered: daysIncluded,
charactersUsed: totalCharacters,
truncated,
},
};
} catch (error) {
Logger.error(`Failed to extract context: ${(error as Error).message}`);
return {
content: `Unable to read your notes file. This could mean:
1. Your notes directory doesn't exist yet
2. Your Daily.md file hasn't been created
3. There's a permissions issue
Try running: notes-sync daily --create
Error details: ${(error as Error).message}`,
metadata: {
daysCovered: 0,
charactersUsed: 0,
truncated: false,
},
};
}
}
private truncateDayContent(daySection: string, maxLength: number): string {
if (daySection.length <= maxLength) return daySection;
const lines = daySection.split('\n');
const header = lines[0]; // Keep the date header
let truncatedContent = header + '\n\n';
// Try to keep structure by preserving section headers
for (const line of lines.slice(2)) {
if (truncatedContent.length + line.length + 1 < maxLength - 50) {
truncatedContent += line + '\n';
} else if (line.startsWith('**') || line.startsWith('#')) {
// Always try to include section headers
if (truncatedContent.length + line.length + 1 < maxLength - 20) {
truncatedContent += line + '\n';
}
break;
}
}
return truncatedContent + '\n[... content truncated for length ...]';
}
private extractActionableItems(response: string): string[] {
// Simple extraction of bullet points or numbered items
const lines = response.split('\n');
const actionItems: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
// Look for bullet points or numbered items that seem actionable
if (trimmed.match(/^[\d\-\*]\s+/) && trimmed.length > 10) {
const cleaned = trimmed.replace(/^[\d\-\*\.\)]\s*/, '').trim();
if (cleaned) {
actionItems.push(cleaned);
}
}
}
return actionItems.slice(0, 5); // Max 5 suggestions
}
async viewNotes(request: ViewNotesRequest): Promise<ViewNotesResponse> {
try {
const content = this.readNotesFile();
switch (request.type) {
case 'today': {
const today = new Date();
const todaySection = this.getDate(today);
if (!todaySection) {
return {
content: `No notes found for today (${today.toLocaleDateString()})`,
metadata: {
type: 'today',
totalLines: 1,
},
};
}
return {
content: todaySection,
metadata: {
type: 'today',
totalLines: todaySection.split('\n').length,
dateRange: {
start: today.toLocaleDateString(),
end: today.toLocaleDateString(),
},
},
};
}
case 'recent': {
const days = request.days || 7;
const dayMap = this.parseDays(content);
const sortedDates = Array.from(dayMap.keys())
.sort((a, b) => new Date(b).getTime() - new Date(a).getTime())
.slice(0, days);
if (sortedDates.length === 0) {
return {
content: `No notes found in the last ${days} days`,
metadata: {
type: 'recent',
daysCovered: 0,
totalLines: 1,
},
};
}
const recentContent = sortedDates
.map(date => dayMap.get(date))
.filter(Boolean)
.join('\n\n');
return {
content: recentContent,
metadata: {
type: 'recent',
daysCovered: sortedDates.length,
totalLines: recentContent.split('\n').length,
dateRange: {
start: sortedDates[sortedDates.length - 1],
end: sortedDates[0],
},
},
};
}
case 'all': {
return {
content: content,
metadata: {
type: 'all',
totalLines: content.split('\n').length,
},
};
}
default:
throw new Error(`Unknown view type: ${request.type}`);
}
} catch (error) {
Logger.error(`Failed to view notes: ${(error as Error).message}`);
throw error;
}
}
}