Jacob Schmidt 0d77300c22 feat: Project Journal backend monorepo
Monorepo with centralized build props, npm workspaces, LlamaSharp AI,
SQLite/SQLCipher storage, Svelte frontend, and unified smoke tests.

Co-Authored-By: Oz <oz-agent@warp.dev>
2026-03-02 20:56:26 -06:00

576 lines
29 KiB
C#

using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Text.Json;
using Journal.Core.Dtos;
using Journal.Core.Models;
using Journal.Core.Services.Ai;
using Journal.Core.Services.Config;
using Journal.Core.Services.Database;
using Journal.Core.Services.Entries;
using Journal.Core.Services.Fragments;
using Journal.Core.Services.Lists;
using Journal.Core.Services.Conversations;
using Journal.Core.Services.Logging;
using Journal.Core.Services.Speech;
using Journal.Core.Services.Todos;
using Journal.Core.Services.Vault;
namespace Journal.Core;
public class Entry(
IFragmentService fragments,
IEntrySearchService entrySearch,
IVaultStorageService vaultStorage,
IJournalDatabaseService database,
IDatabaseSessionService databaseSession,
IJournalConfigService config,
IAiService ai,
ISpeechBridgeService speech,
IS2TService liveSpeech,
IEntryFileService entryFiles,
IListService lists,
ITodoService todos,
ICoachService coach,
IConversationService conversations,
CommandLogger logger)
{
private readonly IFragmentService _fragments = fragments;
private readonly IEntrySearchService _entrySearch = entrySearch;
private readonly IVaultStorageService _vaultStorage = vaultStorage;
private readonly IJournalDatabaseService _database = database;
private readonly IDatabaseSessionService _databaseSession = databaseSession;
private readonly IJournalConfigService _config = config;
private readonly IAiService _ai = ai;
private readonly ISpeechBridgeService _speech = speech;
private readonly IS2TService _liveSpeech = liveSpeech;
private readonly IEntryFileService _entryFiles = entryFiles;
private readonly IListService _lists = lists;
private readonly ITodoService _todos = todos;
private readonly ICoachService _coach = coach;
private readonly IConversationService _conversations = conversations;
private readonly CommandLogger _logger = logger;
private static readonly HashSet<string> VaultSyncActions = new(StringComparer.Ordinal)
{
"entries.save",
"entries.delete",
"templates.save",
"templates.delete",
"fragments.create",
"fragments.update",
"fragments.delete",
"lists.create",
"lists.update",
"lists.delete",
"todos.create",
"todos.update",
"todos.delete",
"todos.items.create",
"todos.items.update",
"todos.items.delete",
"conversations.create",
"conversations.update",
"conversations.delete",
"conversations.chat"
};
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
public async Task RunAsync()
{
string? line;
while ((line = Console.ReadLine()) is not null)
{
var response = await HandleCommandAsync(line);
Console.WriteLine(response);
}
}
public async Task<string> HandleCommandAsync(string json)
{
if (string.IsNullOrWhiteSpace(json))
return Error("Invalid command");
Command? cmd;
try
{
cmd = JsonSerializer.Deserialize<Command>(json, JsonOptions);
}
catch (JsonException)
{
return Error("Invalid command JSON");
}
if (cmd is null || string.IsNullOrWhiteSpace(cmd.Action))
return Error("Invalid command");
var action = cmd.Action.Trim();
var correlationId = string.IsNullOrWhiteSpace(cmd.CorrelationId)
? Guid.NewGuid().ToString("N")
: cmd.CorrelationId.Trim();
CommandLogger.LogStart(action, correlationId, cmd.Payload);
object? result;
try
{
switch (action)
{
case "fragments.list":
result = _fragments.GetAll();
break;
case "fragments.get":
if (!Guid.TryParse(cmd.Id, out var getId))
return Error("Invalid or missing id");
result = _fragments.GetById(getId);
break;
case "fragments.create":
var createDto = DeserializePayload<CreateFragmentDto>(cmd.Payload);
if (createDto is null)
return Error("Missing or invalid payload");
result = _fragments.Create(createDto);
break;
case "fragments.update":
if (!Guid.TryParse(cmd.Id, out var updateId))
return Error("Invalid or missing id");
var updateDto = DeserializePayload<UpdateFragmentDto>(cmd.Payload);
if (updateDto is null)
return Error("Missing or invalid payload");
result = _fragments.Update(updateId, updateDto);
break;
case "fragments.delete":
if (!Guid.TryParse(cmd.Id, out var deleteId))
return Error("Invalid or missing id");
result = _fragments.Remove(deleteId);
break;
case "fragments.search":
result = _fragments.Search(cmd.Type, cmd.Tag);
break;
// ── Lists ────────────────────────────────────────
case "lists.list":
result = _lists.GetAll();
break;
case "lists.get":
if (!Guid.TryParse(cmd.Id, out var getListId))
return Error("Invalid or missing id");
result = _lists.GetById(getListId);
break;
case "lists.create":
var createListDto = DeserializePayload<CreateListDto>(cmd.Payload);
if (createListDto is null)
return Error("Missing or invalid payload");
result = _lists.Create(createListDto);
break;
case "lists.update":
if (!Guid.TryParse(cmd.Id, out var updateListId))
return Error("Invalid or missing id");
var updateListDto = DeserializePayload<UpdateListDto>(cmd.Payload);
if (updateListDto is null)
return Error("Missing or invalid payload");
result = _lists.Update(updateListId, updateListDto);
break;
case "lists.delete":
if (!Guid.TryParse(cmd.Id, out var deleteListId))
return Error("Invalid or missing id");
result = _lists.Remove(deleteListId);
break;
// ── Todos ────────────────────────────────────────
case "todos.list":
result = _todos.GetAllLists();
break;
case "todos.get":
if (!Guid.TryParse(cmd.Id, out var getTodoListId))
return Error("Invalid or missing id");
result = _todos.GetListById(getTodoListId);
break;
case "todos.create":
var createTodoListDto = DeserializePayload<CreateTodoListDto>(cmd.Payload);
if (createTodoListDto is null)
return Error("Missing or invalid payload");
result = _todos.CreateList(createTodoListDto);
break;
case "todos.update":
if (!Guid.TryParse(cmd.Id, out var updateTodoListId))
return Error("Invalid or missing id");
var updateTodoListDto = DeserializePayload<UpdateTodoListDto>(cmd.Payload);
if (updateTodoListDto is null)
return Error("Missing or invalid payload");
result = _todos.UpdateList(updateTodoListId, updateTodoListDto);
break;
case "todos.delete":
if (!Guid.TryParse(cmd.Id, out var deleteTodoListId))
return Error("Invalid or missing id");
result = _todos.RemoveList(deleteTodoListId);
break;
case "todos.items.create":
var createItemDto = DeserializePayload<CreateTodoItemDto>(cmd.Payload);
if (createItemDto is null)
return Error("Missing or invalid payload");
result = _todos.CreateItem(createItemDto);
break;
case "todos.items.update":
if (!Guid.TryParse(cmd.Id, out var updateItemId))
return Error("Invalid or missing id");
var updateItemDto = DeserializePayload<UpdateTodoItemDto>(cmd.Payload);
if (updateItemDto is null)
return Error("Missing or invalid payload");
result = _todos.UpdateItem(updateItemId, updateItemDto);
break;
case "todos.items.delete":
if (!Guid.TryParse(cmd.Id, out var deleteItemId))
return Error("Invalid or missing id");
result = _todos.RemoveItem(deleteItemId);
break;
case "search.entries":
var searchPayload = DeserializePayload<SearchEntriesPayload>(cmd.Payload);
if (searchPayload is null)
return Error("Missing or invalid payload");
var searchRequest = new EntrySearchRequestDto(
Query: searchPayload.Query,
Section: searchPayload.Section,
StartDate: searchPayload.StartDate,
EndDate: searchPayload.EndDate,
Tags: searchPayload.Tags,
Types: searchPayload.Types,
Checked: searchPayload.Checked,
Unchecked: searchPayload.Unchecked);
result = await _entrySearch.SearchEntriesAsync(searchRequest);
break;
case "entries.list":
_ = DeserializePayload<EntryListPayload>(cmd.Payload);
result = _entryFiles.ListEntries();
break;
case "templates.list":
_ = DeserializePayload<EntryTemplateListPayload>(cmd.Payload);
result = _entryFiles.ListTemplates();
break;
case "entries.load":
var loadEntryPayload = DeserializePayload<EntryLoadPayload>(cmd.Payload);
if (loadEntryPayload is null || string.IsNullOrWhiteSpace(loadEntryPayload.FilePath))
return Error("Missing or invalid payload");
result = _entryFiles.LoadEntry(loadEntryPayload.FilePath);
break;
case "templates.load":
var loadTemplatePayload = DeserializePayload<EntryTemplateLoadPayload>(cmd.Payload);
if (loadTemplatePayload is null || string.IsNullOrWhiteSpace(loadTemplatePayload.FilePath))
return Error("Missing or invalid payload");
result = _entryFiles.LoadTemplate(loadTemplatePayload.FilePath);
break;
case "entries.save":
var saveEntryPayload = DeserializePayload<EntrySavePayload>(cmd.Payload);
if (saveEntryPayload is null || string.IsNullOrWhiteSpace(saveEntryPayload.Content))
return Error("Missing or invalid payload");
result = _entryFiles.SaveEntry(saveEntryPayload);
break;
case "templates.save":
var saveTemplatePayload = DeserializePayload<EntryTemplateSavePayload>(cmd.Payload);
if (saveTemplatePayload is null || string.IsNullOrWhiteSpace(saveTemplatePayload.Name))
return Error("Missing or invalid payload");
result = _entryFiles.SaveTemplate(saveTemplatePayload);
break;
case "entries.delete":
var deleteEntryPayload = DeserializePayload<EntryDeletePayload>(cmd.Payload);
if (deleteEntryPayload is null || string.IsNullOrWhiteSpace(deleteEntryPayload.FilePath))
return Error("Missing or invalid payload");
result = _entryFiles.DeleteEntry(deleteEntryPayload.FilePath);
break;
case "templates.delete":
var deleteTemplatePayload = DeserializePayload<EntryTemplateDeletePayload>(cmd.Payload);
if (deleteTemplatePayload is null || string.IsNullOrWhiteSpace(deleteTemplatePayload.FilePath))
return Error("Missing or invalid payload");
result = _entryFiles.DeleteTemplate(deleteTemplatePayload.FilePath);
break;
case "config.get":
result = _config.Current;
break;
case "ai.health":
result = await _ai.HealthAsync();
break;
case "ai.summarize_entry":
var summarizeEntryPayload = DeserializePayload<AiSummarizeEntryPayload>(cmd.Payload);
if (summarizeEntryPayload is null || string.IsNullOrWhiteSpace(summarizeEntryPayload.Content))
return Error("Missing or invalid payload");
result = await _ai.SummarizeEntryAsync(summarizeEntryPayload.Content, summarizeEntryPayload.FileStem);
break;
case "ai.summarize_all":
var summarizeAllPayload = DeserializePayload<AiSummarizeAllPayload>(cmd.Payload);
if (summarizeAllPayload is null)
return Error("Missing or invalid payload");
result = await _ai.SummarizeAllAsync(summarizeAllPayload.Entries ?? []);
break;
case "ai.chat":
var chatPayload = DeserializePayload<AiChatPayload>(cmd.Payload);
if (chatPayload is null || string.IsNullOrWhiteSpace(chatPayload.Prompt))
return Error("Missing or invalid payload");
result = await _ai.ChatAsync(chatPayload.Prompt);
break;
case "ai.embed":
var embedPayload = DeserializePayload<AiEmbedPayload>(cmd.Payload);
if (embedPayload is null || string.IsNullOrWhiteSpace(embedPayload.Content))
return Error("Missing or invalid payload");
result = await _ai.EmbedAsync(embedPayload.Content);
break;
// ── Coach ─────────────────────────────────────────
case "ai.coach.daily":
var coachDailyPayload = DeserializePayload<CoachDailyPayload>(cmd.Payload);
result = await _coach.DailyCheckInAsync(new CoachContextDto(
DateLocal: coachDailyPayload?.DateLocal ?? DateTime.Now.ToString("yyyy-MM-dd"),
RecentEntries: coachDailyPayload?.RecentEntries,
RecentFragments: coachDailyPayload?.RecentFragments,
Preferences: coachDailyPayload?.Preferences));
break;
case "ai.coach.evening":
var coachEveningPayload = DeserializePayload<CoachEveningPayload>(cmd.Payload);
result = await _coach.EveningReviewAsync(new CoachContextDto(
DateLocal: coachEveningPayload?.DateLocal ?? DateTime.Now.ToString("yyyy-MM-dd"),
RecentEntries: coachEveningPayload?.RecentEntries,
RecentFragments: coachEveningPayload?.RecentFragments,
Preferences: coachEveningPayload?.Preferences));
break;
case "ai.coach.weekly":
var coachWeeklyPayload = DeserializePayload<CoachWeeklyPayload>(cmd.Payload);
var now = DateTime.Now;
var weekStart = now.AddDays(-(int)now.DayOfWeek + (int)DayOfWeek.Monday);
result = await _coach.WeeklyReviewAsync(new CoachContextDto(
DateLocal: now.ToString("yyyy-MM-dd"),
WeekStartLocal: coachWeeklyPayload?.WeekStartLocal ?? weekStart.ToString("yyyy-MM-dd"),
WeekEndLocal: coachWeeklyPayload?.WeekEndLocal ?? weekStart.AddDays(6).ToString("yyyy-MM-dd"),
RecentEntries: coachWeeklyPayload?.RecentEntries,
RecentFragments: coachWeeklyPayload?.RecentFragments,
Preferences: coachWeeklyPayload?.Preferences));
break;
// ── Conversations ──────────────────────────────────
case "conversations.list":
result = _conversations.GetAll();
break;
case "conversations.get":
if (!Guid.TryParse(cmd.Id, out var getConvId))
return Error("Invalid or missing id");
result = _conversations.GetById(getConvId);
break;
case "conversations.create":
var convCreatePayload = DeserializePayload<ConversationCreatePayload>(cmd.Payload);
if (convCreatePayload is null || string.IsNullOrWhiteSpace(convCreatePayload.Title))
return Error("Missing or invalid payload");
result = _conversations.Create(new CreateConversationDto(convCreatePayload.Title));
break;
case "conversations.update":
if (!Guid.TryParse(cmd.Id, out var updateConvId))
return Error("Invalid or missing id");
var convUpdatePayload = DeserializePayload<ConversationUpdatePayload>(cmd.Payload);
if (convUpdatePayload is null)
return Error("Missing or invalid payload");
result = _conversations.Update(updateConvId, new UpdateConversationDto(convUpdatePayload.Title));
break;
case "conversations.delete":
if (!Guid.TryParse(cmd.Id, out var deleteConvId))
return Error("Invalid or missing id");
result = _conversations.Remove(deleteConvId);
break;
case "conversations.chat":
var convChatPayload = DeserializePayload<ConversationChatPayload>(cmd.Payload);
if (convChatPayload is null || string.IsNullOrWhiteSpace(convChatPayload.Prompt)
|| !Guid.TryParse(convChatPayload.ConversationId, out var chatConvId))
return Error("Missing or invalid payload");
// Save user message
var userMsg = _conversations.AddMessage(chatConvId, "user", convChatPayload.Prompt);
// Build history from existing messages
var history = _conversations.GetMessages(chatConvId)
.Where(m => m.Id != userMsg.Id)
.Select(m => (m.Role, m.Text))
.ToList();
// Get AI response with full conversation context
var aiResponse = await _ai.ChatWithHistoryAsync(history, convChatPayload.Prompt);
// Save AI response
var assistantMsg = _conversations.AddMessage(chatConvId, "assistant", aiResponse);
result = new { userMessage = userMsg, assistantMessage = assistantMsg };
break;
case "speech.devices.list":
result = await _speech.ListDevicesAsync();
break;
case "speech.transcribe":
var speechPayload = DeserializePayload<SpeechTranscribePayload>(cmd.Payload);
if (speechPayload is null)
return Error("Missing or invalid payload");
var audioBase64 = !string.IsNullOrWhiteSpace(speechPayload.AudioBase64)
? speechPayload.AudioBase64
: speechPayload.Audio_Base64;
var text = speechPayload.Text;
var whisperModel = !string.IsNullOrWhiteSpace(speechPayload.WhisperModel)
? speechPayload.WhisperModel
: speechPayload.Whisper_Model;
var simulateDelayMs = speechPayload.SimulateDelayMs ?? speechPayload.Simulate_Delay_Ms;
if (string.IsNullOrWhiteSpace(audioBase64) && string.IsNullOrWhiteSpace(text))
return Error("Missing or invalid payload");
result = await _speech.TranscribeAsync(new SpeechTranscribeRequestDto(
AudioBase64: audioBase64,
Engine: speechPayload.Engine,
WhisperModel: whisperModel,
Text: text,
SimulateDelayMs: simulateDelayMs));
break;
case "speech.live.start":
result = await _liveSpeech.StartAsync();
break;
case "speech.live.stop":
result = await _liveSpeech.StopAsync();
break;
case "speech.live.poll":
var livePollPayload = DeserializePayload<S2TPollPayload>(cmd.Payload);
var maxItems = livePollPayload?.MaxItems ?? 8;
if (maxItems <= 0)
maxItems = 1;
if (maxItems > 64)
maxItems = 64;
result = await _liveSpeech.PollAsync(maxItems);
break;
case "vault.initialize":
var initPayload = DeserializePayload<VaultInitializePayload>(cmd.Payload);
if (initPayload is null || string.IsNullOrWhiteSpace(initPayload.Password) || string.IsNullOrWhiteSpace(initPayload.VaultDirectory))
return Error("Missing or invalid payload");
Directory.CreateDirectory(initPayload.VaultDirectory);
result = true;
break;
case "vault.load_all":
var loadPayload = DeserializePayload<VaultPayload>(cmd.Payload);
if (loadPayload is null)
return Error("Missing or invalid payload");
var vaultStorageDirectory = ResolveVaultStorageDirectory();
var loaded = _vaultStorage.LoadAllVaults(loadPayload.Password, loadPayload.VaultDirectory, vaultStorageDirectory);
if (loaded)
_databaseSession.SetPassword(loadPayload.Password);
result = loaded;
break;
case "vault.rebuild_all":
var rebuildPayload = DeserializePayload<VaultPayload>(cmd.Payload);
if (rebuildPayload is null)
return Error("Missing or invalid payload");
_databaseSession.CloseConnection();
_vaultStorage.RebuildAllVaults(rebuildPayload.Password, rebuildPayload.VaultDirectory, ResolveVaultStorageDirectory());
result = true;
break;
case "vault.clear_data_directory":
var clearPayload = DeserializePayload<ClearDataPayload>(cmd.Payload);
if (clearPayload is null)
return Error("Missing or invalid payload");
if (_databaseSession is IDisposable disposableSession)
disposableSession.Dispose();
_vaultStorage.ClearDataDirectory(ResolveVaultStorageDirectory());
result = true;
break;
case "db.status":
var dbStatusPayload = DeserializePayload<DatabasePayload>(cmd.Payload);
if (dbStatusPayload is null || string.IsNullOrWhiteSpace(dbStatusPayload.Password))
return Error("Missing or invalid payload");
result = _database.GetStatus(dbStatusPayload.Password);
break;
case "db.initialize_schema":
var dbInitPayload = DeserializePayload<DatabasePayload>(cmd.Payload);
if (dbInitPayload is null || string.IsNullOrWhiteSpace(dbInitPayload.Password))
return Error("Missing or invalid payload");
var initResult = _database.HydrateWorkspace(dbInitPayload.Password);
result = new
{
initialized = initResult.RuntimeReady,
databasePath = initResult.DatabasePath,
initResult.Message
};
break;
case "db.hydrate_workspace":
var dbHydratePayload = DeserializePayload<DatabasePayload>(cmd.Payload);
if (dbHydratePayload is null || string.IsNullOrWhiteSpace(dbHydratePayload.Password))
return Error("Missing or invalid payload");
result = _database.HydrateWorkspace(dbHydratePayload.Password);
_databaseSession.SetPassword(dbHydratePayload.Password);
break;
default:
CommandLogger.LogFailure(action, correlationId, "unknown_action");
return Error($"Unknown action: {action}");
}
}
catch (JsonException)
{
CommandLogger.LogFailure(action, correlationId, "invalid_payload_json");
return Error("Missing or invalid payload");
}
catch (ValidationException ex)
{
CommandLogger.LogFailure(action, correlationId, "validation", ex.Message);
return Error(ex.Message);
}
catch (ArgumentException ex)
{
CommandLogger.LogFailure(action, correlationId, "argument", ex.Message);
return Error(ex.Message);
}
catch (TimeoutException ex)
{
CommandLogger.LogFailure(action, correlationId, "timeout", ex.Message);
return Error(ex.Message);
}
catch (InvalidOperationException ex)
{
CommandLogger.LogFailure(action, correlationId, "invalid_operation", ex.Message);
return Error(ex.Message);
}
catch (FileNotFoundException ex)
{
CommandLogger.LogFailure(action, correlationId, "not_found", ex.Message);
return Error(ex.Message);
}
catch
{
CommandLogger.LogFailure(action, correlationId, "internal_error");
return Error("Internal error");
}
TryAutoSyncVault(action, correlationId);
CommandLogger.LogSuccess(action, correlationId);
return JsonSerializer.Serialize(new { ok = true, data = result });
}
private static string Error(string message)
=> JsonSerializer.Serialize(new { ok = false, error = message });
private static T? DeserializePayload<T>(JsonElement? payload)
{
if (payload is null)
return default;
return payload.Value.Deserialize<T>(JsonOptions);
}
private void TryAutoSyncVault(string action, string correlationId)
{
if (!VaultSyncActions.Contains(action))
return;
if (!_databaseSession.TryGetSession(out var password))
return;
try
{
var config = _config.Current;
_databaseSession.CloseConnection();
_vaultStorage.RebuildAllVaults(password, config.VaultDirectory, ResolveVaultStorageDirectory());
}
catch (Exception ex)
{
CommandLogger.LogFailure(action, correlationId, "vault_auto_sync_failed", ex.Message);
}
}
private string ResolveVaultStorageDirectory()
{
var dbPath = _database.GetDatabasePath();
var directory = Path.GetDirectoryName(dbPath);
return string.IsNullOrWhiteSpace(directory)
? Path.GetFullPath(".")
: Path.GetFullPath(directory);
}
}