diff --git a/Journal.AI/Coach-Rules.txt b/Journal.AI/Coach-Rules.txt
new file mode 100644
index 0000000..b65dc90
--- /dev/null
+++ b/Journal.AI/Coach-Rules.txt
@@ -0,0 +1,35 @@
+You are a personal coach inside a private journaling app.
+Your goals:
+- Be supportive, practical, and brief.
+- Ask at most {{maxQuestions}} questions.
+- Suggest at most {{maxNextActions}} next actions.
+- Default to small, realistic steps.
+
+Hard rules:
+- Do not diagnose medical or mental health conditions.
+- Do not use shame, guilt, or alarmist language.
+- Do not claim certainty about the user's feelings or motives.
+- Treat all suggestions as optional proposals.
+- Output MUST be a single valid JSON object with NO text before or after it.
+- Do NOT output explanations, templates, or examples — only the final JSON.
+- If context is missing, make gentle assumptions and ask 1 clarifying question.
+
+Evidence:
+- When making an observation or suggestion, include evidence snippets with recordId when available.
+
+You MUST respond with a single valid JSON object. No text before or after the JSON.
+
+Required JSON schema:
+{
+ "kind": "daily_checkin" | "evening_review" | "weekly_review",
+ "title": "",
+ "summary": "<2-4 sentence summary>",
+ "questions": [""],
+ "suggestedNextActions": [""],
+ "suggestedTags": [""],
+ "evidence": [{"recordId": null, "text": ""}],
+ "patchProposal": null
+}
+The top-level "kind" MUST be exactly one of: "daily_checkin", "evening_review", "weekly_review".
+Set patchProposal to null unless a draft is clearly helpful. If needed use: {"kind": "createDraft", "description": "", "content": ""}.
+Do NOT confuse patchProposal.kind with the top-level kind field.
diff --git a/Journal.AI/Daily-Check-In.txt b/Journal.AI/Daily-Check-In.txt
new file mode 100644
index 0000000..c879030
--- /dev/null
+++ b/Journal.AI/Daily-Check-In.txt
@@ -0,0 +1,20 @@
+{{CoachRules}}
+
+Task:
+Generate a DAILY CHECK-IN plan for date {{dateLocal}}.
+
+Context (JSON):
+{{contextJson}}
+
+Preferences (JSON):
+{{preferencesJson}}
+
+Output:
+Return a CoachPlanDto JSON with kind="daily_checkin".
+Include:
+- A short title
+- A 2-4 sentence summary
+- 1-{{maxQuestions}} questions
+- 1-{{maxNextActions}} suggestedNextActions
+- suggestedTags if relevant
+- patchProposal that creates a fragment or dailyEntry draft ONLY if it is clearly helpful.
\ No newline at end of file
diff --git a/Journal.AI/Evening-Review.txt b/Journal.AI/Evening-Review.txt
new file mode 100644
index 0000000..788198e
--- /dev/null
+++ b/Journal.AI/Evening-Review.txt
@@ -0,0 +1,19 @@
+{{CoachRules}}
+
+Task:
+Generate an EVENING REVIEW for date {{dateLocal}}.
+
+Context (JSON):
+{{contextJson}}
+
+Preferences (JSON):
+{{preferencesJson}}
+
+Output:
+Return a CoachPlanDto JSON with kind="evening_review".
+Include:
+- Brief recap of today
+- 2-4 observations with evidence
+- 1-{{maxQuestions}} reflection questions
+- 1-{{maxNextActions}} next actions for tomorrow
+- patchProposal that creates: (optional) tomorrow-intent fragment AND/OR todos.
\ No newline at end of file
diff --git a/Journal.AI/Journal.AI.csproj b/Journal.AI/Journal.AI.csproj
new file mode 100644
index 0000000..97186d9
--- /dev/null
+++ b/Journal.AI/Journal.AI.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Journal.AI/LlamaSharpAiService.cs b/Journal.AI/LlamaSharpAiService.cs
new file mode 100644
index 0000000..5827f28
--- /dev/null
+++ b/Journal.AI/LlamaSharpAiService.cs
@@ -0,0 +1,336 @@
+using System.Text;
+using System.Text.RegularExpressions;
+using Journal.Core.Dtos;
+using Journal.Core.Models;
+using Journal.Core.Services.Ai;
+using LLama;
+using LLama.Common;
+using LLama.Sampling;
+
+namespace Journal.AI;
+
+public sealed partial class LlamaSharpAiService(JournalConfig config) : IAiService, IDisposable
+{
+ private const string DefaultModelUrl =
+ "https://huggingface.co/microsoft/Phi-3-mini-4k-instruct-gguf/resolve/main/Phi-3-mini-4k-instruct-q4.gguf";
+ private const string DefaultModelFileName = "Phi-3-mini-4k-instruct-q4.gguf";
+ private const string ModelSubDirectory = "ai-models";
+
+ private readonly string _configuredModelPath = config.GgufModelPath;
+ private readonly uint _contextSize = (uint)Math.Clamp(config.ModelContextTokens, 512, 4096);
+ private readonly int _gpuLayers = config.LlamaCppTimeout;
+
+ private readonly Lock _sync = new();
+ private string? _resolvedModelPath;
+ private LLamaWeights? _weights;
+ private bool _disposed;
+
+ public Task HealthAsync(CancellationToken cancellationToken = default)
+ {
+ var resolved = _resolvedModelPath ?? _configuredModelPath;
+ var modelExists = File.Exists(resolved) || File.Exists(GetDefaultModelPath());
+ var loaded = _weights is not null;
+ return Task.FromResult(new AiHealthDto(
+ Provider: "llamasharp",
+ Enabled: true,
+ Healthy: modelExists || loaded,
+ Message: loaded
+ ? "Model loaded."
+ : modelExists
+ ? "Model found (will load on first use)."
+ : "Model not found locally. It will be downloaded on first use."));
+ }
+
+ private static string BuildChatSystemPrompt()
+ {
+ var dateStr = DateTime.Now.ToString("MMMM d, yyyy");
+ return $"You are a supportive conversational coach inside a private journaling app. " +
+ $"Today's date is {dateStr}. " +
+ $"Reply in plain natural language only. Never output JSON, code blocks, or structured data. " +
+ $"Be warm, practical, and concise. Do not repeat yourself.";
+ }
+
+ public async Task ChatAsync(string prompt, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(prompt))
+ throw new ArgumentException("Prompt is required.", nameof(prompt));
+
+ var raw = await RunSessionAsync(prompt, BuildChatSystemPrompt(),
+ maxTokens: 512, cancellationToken: cancellationToken);
+ return CleanChatResponse(raw);
+ }
+
+ public async Task ChatWithHistoryAsync(IReadOnlyList<(string Role, string Text)> history,
+ string prompt, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(prompt))
+ throw new ArgumentException("Prompt is required.", nameof(prompt));
+
+ var modelPath = await EnsureModelAsync(cancellationToken);
+ EnsureWeights(modelPath);
+
+ using var context = _weights!.CreateContext(new ModelParams(modelPath)
+ {
+ ContextSize = _contextSize,
+ GpuLayerCount = _gpuLayers
+ });
+
+ var executor = new StatelessExecutor(_weights!, context.Params);
+
+ // Build multi-turn Phi-3 prompt with full conversation history
+ var sb = new StringBuilder();
+ sb.Append($"<|system|>\n{BuildChatSystemPrompt()}<|end|>\n");
+
+ foreach (var (role, text) in history)
+ {
+ var tag = string.Equals(role, "user", StringComparison.OrdinalIgnoreCase) ? "user" : "assistant";
+ sb.Append($"<|{tag}|>\n{text}<|end|>\n");
+ }
+
+ sb.Append($"<|user|>\n{prompt}<|end|>\n");
+ sb.Append("<|assistant|>\n");
+
+ var inferenceParams = new InferenceParams
+ {
+ MaxTokens = 512,
+ AntiPrompts = ["<|user|>", "<|system|>", "<|end|>", "<|endoftext|>"],
+ SamplingPipeline = new DefaultSamplingPipeline { Temperature = 0.7f }
+ };
+
+ var result = new StringBuilder();
+ await foreach (var token in executor.InferAsync(sb.ToString(), inferenceParams, cancellationToken))
+ {
+ result.Append(token);
+ }
+
+ return CleanChatResponse(StripSpecialTokens(result.ToString()));
+ }
+
+ internal async Task ChatJsonAsync(string prompt, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(prompt))
+ throw new ArgumentException("Prompt is required.", nameof(prompt));
+
+ var dateStr = DateTime.Now.ToString("MMMM d, yyyy");
+ return await RunSessionAsync(prompt,
+ $"You are a coaching assistant inside a private journaling app. " +
+ $"Today's date is {dateStr}. " +
+ $"You MUST respond with a single valid JSON object. No text before or after the JSON.",
+ maxTokens: 1024, cancellationToken: cancellationToken);
+ }
+
+ public async Task SummarizeEntryAsync(string content, string? fileStem = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(content))
+ throw new ArgumentException("Entry content is required.", nameof(content));
+
+ var prompt = fileStem is not null
+ ? $"Summarize this journal entry ({fileStem}) concisely:\n\n{content}"
+ : $"Summarize this journal entry concisely:\n\n{content}";
+
+ return await ChatAsync(prompt, cancellationToken);
+ }
+
+ public async Task SummarizeAllAsync(IReadOnlyList entries,
+ CancellationToken cancellationToken = default)
+ {
+ if (entries is null || entries.Count == 0)
+ return "No entries to summarize.";
+
+ var combined = string.Join("\n\n---\n\n", entries);
+ var prompt = $"Summarize the following {entries.Count} journal entries into a concise overview:\n\n{combined}";
+
+ return await ChatAsync(prompt, cancellationToken);
+ }
+
+ public async Task> EmbedAsync(string content,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(content))
+ throw new ArgumentException("Content is required.", nameof(content));
+
+ var modelPath = await EnsureModelAsync(cancellationToken);
+
+ try
+ {
+ EnsureWeights(modelPath);
+ var embedder = new LLamaEmbedder(_weights!, new ModelParams(modelPath)
+ {
+ Embeddings = true,
+ ContextSize = _contextSize,
+ GpuLayerCount = _gpuLayers
+ });
+
+ var embeddingArrays = await embedder.GetEmbeddings(content, cancellationToken);
+ var result = new List();
+
+ foreach (var arr in embeddingArrays)
+ {
+ foreach (var val in arr)
+ {
+ result.Add(val);
+ }
+ }
+
+ return result;
+ }
+ catch
+ {
+ return [];
+ }
+ }
+
+ // ── Model download (mirrors LocalWhisperS2TService.EnsureModelAsync) ───
+
+ private static string GetDefaultModelPath()
+ {
+ var modelDirectory = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "ProjectJournal",
+ ModelSubDirectory);
+ return Path.Combine(modelDirectory, DefaultModelFileName);
+ }
+
+ private async Task EnsureModelAsync(CancellationToken cancellationToken = default)
+ {
+ // 1. Configured path takes priority if the file already exists
+ if (File.Exists(_configuredModelPath))
+ return _configuredModelPath;
+
+ // 2. Check the standard app-data location
+ var defaultPath = GetDefaultModelPath();
+ if (File.Exists(defaultPath))
+ return defaultPath;
+
+ // 3. Download from HuggingFace
+ var modelDirectory = Path.GetDirectoryName(defaultPath)!;
+ Directory.CreateDirectory(modelDirectory);
+
+ var tempPath = defaultPath + ".download";
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ cts.CancelAfter(TimeSpan.FromMinutes(30));
+
+ using var httpClient = new HttpClient();
+ httpClient.Timeout = TimeSpan.FromMinutes(30);
+
+ using var response = await httpClient.GetAsync(DefaultModelUrl,
+ HttpCompletionOption.ResponseHeadersRead, cts.Token);
+ response.EnsureSuccessStatusCode();
+
+ await using var contentStream = await response.Content.ReadAsStreamAsync(cts.Token);
+ await using var fileStream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None);
+ await contentStream.CopyToAsync(fileStream, cts.Token);
+ await fileStream.FlushAsync(cts.Token);
+ fileStream.Close();
+
+ File.Move(tempPath, defaultPath, overwrite: true);
+ return defaultPath;
+ }
+
+ // ── Session / weights lifecycle ────────────────────────────────────────
+
+ private async Task RunSessionAsync(string prompt, string systemPrompt,
+ int maxTokens, CancellationToken cancellationToken)
+ {
+ var modelPath = await EnsureModelAsync(cancellationToken);
+ EnsureWeights(modelPath);
+
+ // Fresh context per call — prevents KV cache accumulation across requests
+ using var context = _weights!.CreateContext(new ModelParams(modelPath)
+ {
+ ContextSize = _contextSize,
+ GpuLayerCount = _gpuLayers
+ });
+
+ // Use StatelessExecutor with explicit Phi-3 chat template so the model
+ // never sees raw system text it can echo back to the user.
+ var executor = new StatelessExecutor(_weights!, context.Params);
+
+ var fullPrompt = $"<|system|>\n{systemPrompt}<|end|>\n" +
+ $"<|user|>\n{prompt}<|end|>\n" +
+ $"<|assistant|>\n";
+
+ var inferenceParams = new InferenceParams
+ {
+ MaxTokens = maxTokens,
+ AntiPrompts = [
+ "<|user|>",
+ "<|system|>",
+ "<|end|>",
+ "<|endoftext|>",
+ ],
+ SamplingPipeline = new DefaultSamplingPipeline
+ {
+ Temperature = 0.7f
+ }
+ };
+
+ var sb = new StringBuilder();
+
+ await foreach (var token in executor.InferAsync(fullPrompt, inferenceParams, cancellationToken))
+ {
+ sb.Append(token);
+ }
+
+ return StripSpecialTokens(sb.ToString());
+ }
+
+ /// Strips only Phi-3 special tokens — safe for JSON output.
+ private static string StripSpecialTokens(string raw)
+ {
+ var text = raw;
+ foreach (var marker in new[] { "<|assistant|>", "<|user|>", "<|system|>", "<|end|>", "<|endoftext|>" })
+ text = text.Replace(marker, "");
+ return text.Trim();
+ }
+
+ // Matches role labels like "System:", "**System:**", "**Assistant:**", "User:" etc.
+ private static readonly Regex RoleMarkerRegex = MyRegex();
+
+ /// Aggressive cleanup for conversational (non-JSON) responses.
+ private static string CleanChatResponse(string raw)
+ {
+ var text = StripSpecialTokens(raw);
+
+ // Strip role markers in any formatting variant (plain, bold-markdown, etc.)
+ text = RoleMarkerRegex.Replace(text, "");
+
+ // Remove orphaned bold markers left behind after stripping
+ text = text.Replace("**", "");
+
+ // Collapse runs of 3+ newlines into 2
+ text = Regex.Replace(text, @"\n{3,}", "\n\n");
+
+ return text.Trim();
+ }
+
+ private void EnsureWeights(string modelPath)
+ {
+ if (_weights is not null) return;
+
+ lock (_sync)
+ {
+ if (_weights is not null) return;
+
+ _resolvedModelPath = modelPath;
+ _weights = LLamaWeights.LoadFromFile(new ModelParams(modelPath)
+ {
+ ContextSize = _contextSize,
+ GpuLayerCount = _gpuLayers
+ });
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ _weights?.Dispose();
+ }
+
+ [GeneratedRegex(@"\*{0,2}(System|Assistant|User):\*{0,2}", RegexOptions.IgnoreCase | RegexOptions.Compiled, "en-US")]
+ private static partial Regex MyRegex();
+ [GeneratedRegex(@"\n{3,}")]
+ private static partial Regex MyRegex1();
+}
diff --git a/Journal.AI/LlamaSharpCoachService.cs b/Journal.AI/LlamaSharpCoachService.cs
new file mode 100644
index 0000000..5e17f02
--- /dev/null
+++ b/Journal.AI/LlamaSharpCoachService.cs
@@ -0,0 +1,181 @@
+using System.Reflection;
+using System.Text;
+using System.Text.Json;
+using Journal.Core.Dtos;
+using Journal.Core.Services.Ai;
+
+namespace Journal.AI;
+
+public sealed class LlamaSharpCoachService(LlamaSharpAiService ai) : ICoachService
+{
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true
+ };
+
+ private readonly LlamaSharpAiService _ai = ai;
+ private readonly string _coachRules = LoadEmbeddedResource("Coach-Rules.txt");
+ private readonly string _dailyTemplate = LoadEmbeddedResource("Daily-Check-In.txt");
+ private readonly string _eveningTemplate = LoadEmbeddedResource("Evening-Review.txt");
+ private readonly string _weeklyTemplate = LoadEmbeddedResource("Weekly-Review.txt");
+
+ public async Task DailyCheckInAsync(CoachContextDto context,
+ CancellationToken cancellationToken = default)
+ {
+ var prefs = context.Preferences ?? new CoachPreferencesDto();
+ var prompt = InterpolateTemplate(_dailyTemplate, context, prefs);
+ return await RunCoachPromptAsync(prompt, "daily_checkin", cancellationToken);
+ }
+
+ public async Task EveningReviewAsync(CoachContextDto context,
+ CancellationToken cancellationToken = default)
+ {
+ var prefs = context.Preferences ?? new CoachPreferencesDto();
+ var prompt = InterpolateTemplate(_eveningTemplate, context, prefs);
+ return await RunCoachPromptAsync(prompt, "evening_review", cancellationToken);
+ }
+
+ public async Task WeeklyReviewAsync(CoachContextDto context,
+ CancellationToken cancellationToken = default)
+ {
+ var prefs = context.Preferences ?? new CoachPreferencesDto();
+ var prompt = InterpolateTemplate(_weeklyTemplate, context, prefs);
+ return await RunCoachPromptAsync(prompt, "weekly_review", cancellationToken);
+ }
+
+ private async Task RunCoachPromptAsync(string prompt, string fallbackKind,
+ CancellationToken cancellationToken)
+ {
+ var raw = await _ai.ChatJsonAsync(prompt, cancellationToken);
+
+ // Try to extract JSON from the response
+ var json = ExtractJson(raw);
+ if (json is not null)
+ {
+ try
+ {
+ var parsed = JsonSerializer.Deserialize(json, JsonOptions);
+ if (parsed is not null)
+ return parsed with { Kind = fallbackKind };
+ }
+ catch (JsonException)
+ {
+ // Fall through to fallback
+ }
+ }
+
+ // Fallback: wrap raw text into a CoachPlanDto
+ return new CoachPlanDto(
+ Kind: fallbackKind,
+ Title: "Coach Response",
+ Summary: raw,
+ Questions: [],
+ SuggestedNextActions: [],
+ SuggestedTags: [],
+ Evidence: []);
+ }
+
+ private string InterpolateTemplate(string template, CoachContextDto context, CoachPreferencesDto prefs)
+ {
+ var contextJson = JsonSerializer.Serialize(new
+ {
+ recentEntries = context.RecentEntries ?? [],
+ recentFragments = context.RecentFragments ?? []
+ });
+
+ var preferencesJson = JsonSerializer.Serialize(new
+ {
+ prefs.MaxQuestions,
+ prefs.MaxNextActions
+ });
+
+ var result = template
+ .Replace("{{CoachRules}}", _coachRules)
+ .Replace("{{dateLocal}}", context.DateLocal)
+ .Replace("{{weekStartLocal}}", context.WeekStartLocal ?? "")
+ .Replace("{{weekEndLocal}}", context.WeekEndLocal ?? "")
+ .Replace("{{contextJson}}", contextJson)
+ .Replace("{{preferencesJson}}", preferencesJson)
+ .Replace("{{maxQuestions}}", prefs.MaxQuestions.ToString())
+ .Replace("{{maxNextActions}}", prefs.MaxNextActions.ToString());
+
+ return result;
+ }
+
+ private static string? ExtractJson(string text)
+ {
+ // 1. Try ```json ... ``` code block
+ var codeBlockStart = text.IndexOf("```json", StringComparison.Ordinal);
+ if (codeBlockStart >= 0)
+ {
+ var jsonStart = text.IndexOf('{', codeBlockStart);
+ var codeBlockEnd = text.IndexOf("```", codeBlockStart + 7, StringComparison.Ordinal);
+ if (jsonStart >= 0 && codeBlockEnd > jsonStart)
+ {
+ var candidate = text[jsonStart..codeBlockEnd].Trim();
+ if (TryValidateJson(candidate))
+ return candidate;
+ }
+ }
+
+ // 2. Look specifically for {"kind" — the expected first field of CoachPlanDto
+ var kindMarker = text.IndexOf("{\"kind\"", StringComparison.Ordinal);
+ if (kindMarker < 0)
+ kindMarker = text.IndexOf("{ \"kind\"", StringComparison.Ordinal);
+ if (kindMarker >= 0)
+ {
+ var lastBrace = text.LastIndexOf('}');
+ if (lastBrace > kindMarker)
+ {
+ var candidate = text[kindMarker..(lastBrace + 1)];
+ if (TryValidateJson(candidate))
+ return candidate;
+ }
+ }
+
+ // 3. Fallback: try each { position until one parses as valid JSON
+ var searchFrom = 0;
+ var globalLastBrace = text.LastIndexOf('}');
+ while (searchFrom < text.Length && globalLastBrace > searchFrom)
+ {
+ var bracePos = text.IndexOf('{', searchFrom);
+ if (bracePos < 0 || bracePos >= globalLastBrace)
+ break;
+
+ var candidate = text[bracePos..(globalLastBrace + 1)];
+ if (TryValidateJson(candidate))
+ return candidate;
+
+ searchFrom = bracePos + 1;
+ }
+
+ return null;
+ }
+
+ private static bool TryValidateJson(string json)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ return doc.RootElement.ValueKind == JsonValueKind.Object;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static string LoadEmbeddedResource(string fileName)
+ {
+ var assembly = Assembly.GetExecutingAssembly();
+ var resourceName = assembly.GetManifestResourceNames()
+ .FirstOrDefault(n => n.EndsWith(fileName, StringComparison.OrdinalIgnoreCase));
+
+ if (resourceName is null)
+ throw new FileNotFoundException($"Embedded resource not found: {fileName}");
+
+ using var stream = assembly.GetManifestResourceStream(resourceName)!;
+ using var reader = new StreamReader(stream, Encoding.UTF8);
+ return reader.ReadToEnd().Trim();
+ }
+}
diff --git a/Journal.AI/ServiceCollectionExtensions.cs b/Journal.AI/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..a8a40cb
--- /dev/null
+++ b/Journal.AI/ServiceCollectionExtensions.cs
@@ -0,0 +1,73 @@
+using Journal.Core.Services.Ai;
+using Journal.Core.Services.Config;
+using Journal.Core.Services.Sidecar;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Journal.AI;
+
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// Registers LLamaSharp-based AI and Coach services.
+ /// Call this AFTER AddFragmentServices() so that IJournalConfigService is available.
+ /// When the provider is "llamasharp", this replaces the default IAiService registration.
+ ///
+ public static IServiceCollection AddLlamaSharpServices(this IServiceCollection services)
+ {
+ // Override IAiService — last registration wins in MS DI
+ services.AddSingleton(provider =>
+ {
+ var config = provider.GetRequiredService().Current;
+
+ if (string.Equals(config.AiProvider, "llamasharp", StringComparison.OrdinalIgnoreCase))
+ {
+ try
+ {
+ return new LlamaSharpAiService(config);
+ }
+ catch (Exception ex)
+ {
+ return new DisabledAiService(
+ provider: "llamasharp",
+ message: $"LLamaSharp unavailable: {ex.Message}",
+ healthy: false);
+ }
+ }
+
+ if (string.Equals(config.AiProvider, "python-sidecar", StringComparison.OrdinalIgnoreCase))
+ {
+ try
+ {
+ return new PythonSidecarAiService(config);
+ }
+ catch (Exception ex)
+ {
+ return new DisabledAiService(
+ provider: "python-sidecar",
+ message: $"Python AI sidecar unavailable: {ex.Message}",
+ healthy: false);
+ }
+ }
+
+ return new DisabledAiService(config.AiProvider);
+ });
+
+ // Register coach service
+ services.AddSingleton(provider =>
+ {
+ var config = provider.GetRequiredService().Current;
+ var ai = provider.GetRequiredService();
+
+ if (ai is LlamaSharpAiService llamaAi)
+ return new LlamaSharpCoachService(llamaAi);
+
+ if (string.Equals(config.AiProvider, "none", StringComparison.OrdinalIgnoreCase))
+ return new DisabledCoachService();
+
+ return new DisabledCoachService(
+ $"Coach requires llamasharp provider (current: {config.AiProvider}).");
+ });
+
+ return services;
+ }
+}
diff --git a/Journal.AI/Weekly-Review.txt b/Journal.AI/Weekly-Review.txt
new file mode 100644
index 0000000..8e1c52a
--- /dev/null
+++ b/Journal.AI/Weekly-Review.txt
@@ -0,0 +1,19 @@
+{{CoachRules}}
+
+Task:
+Generate a WEEKLY REVIEW for {{weekStartLocal}} to {{weekEndLocal}}.
+
+Context (JSON):
+{{contextJson}}
+
+Preferences (JSON):
+{{preferencesJson}}
+
+Output:
+Return a CoachPlanDto JSON with kind="weekly_review".
+Include:
+- Themes (top 2-4) and blockers (top 1-3)
+- Wins (top 1-3)
+- 1-{{maxQuestions}} questions to set next week’s focus
+- 2-{{maxNextActions}} suggestedNextActions
+- patchProposal should only propose creating todos or a planning fragment, not editing old entries.
\ No newline at end of file
diff --git a/Journal.App/src/lib/backend/ai.ts b/Journal.App/src/lib/backend/ai.ts
new file mode 100644
index 0000000..0614287
--- /dev/null
+++ b/Journal.App/src/lib/backend/ai.ts
@@ -0,0 +1,222 @@
+import { sendCommand } from "./client";
+import { pickCase } from "./normalize";
+
+// ── Public types ────────────────────────────────────────────────
+
+export type AiHealthDto = {
+ provider: string;
+ enabled: boolean;
+ healthy: boolean;
+ message: string;
+};
+
+export type CoachEvidenceDto = {
+ recordId: string | null;
+ text: string;
+};
+
+export type CoachPatchProposalDto = {
+ kind: string;
+ description: string | null;
+ content: string | null;
+};
+
+export type CoachPlanDto = {
+ kind: string;
+ title: string;
+ summary: string;
+ questions: string[];
+ suggestedNextActions: string[];
+ suggestedTags: string[];
+ evidence: CoachEvidenceDto[];
+ patchProposal: CoachPatchProposalDto | null;
+};
+
+export type CoachPreferencesDto = {
+ maxQuestions?: number;
+ maxNextActions?: number;
+};
+
+export type CoachSessionPayload = {
+ dateLocal?: string;
+ weekStartLocal?: string;
+ weekEndLocal?: string;
+ recentEntries?: string[];
+ recentFragments?: string[];
+ preferences?: CoachPreferencesDto;
+};
+
+// ── Raw (PascalCase) variants for normalization ─────────────────
+
+type AiHealthDtoRaw = {
+ provider?: string;
+ enabled?: boolean;
+ healthy?: boolean;
+ message?: string;
+ Provider?: string;
+ Enabled?: boolean;
+ Healthy?: boolean;
+ Message?: string;
+};
+
+type CoachEvidenceDtoRaw = {
+ recordId?: string | null;
+ text?: string;
+ RecordId?: string | null;
+ Text?: string;
+};
+
+type CoachPatchProposalDtoRaw = {
+ kind?: string;
+ description?: string | null;
+ content?: string | null;
+ Kind?: string;
+ Description?: string | null;
+ Content?: string | null;
+};
+
+type CoachPlanDtoRaw = {
+ kind?: string;
+ title?: string;
+ summary?: string;
+ questions?: string[];
+ suggestedNextActions?: string[];
+ suggestedTags?: string[];
+ evidence?: CoachEvidenceDtoRaw[];
+ patchProposal?: CoachPatchProposalDtoRaw | null;
+ Kind?: string;
+ Title?: string;
+ Summary?: string;
+ Questions?: string[];
+ SuggestedNextActions?: string[];
+ SuggestedTags?: string[];
+ Evidence?: CoachEvidenceDtoRaw[];
+ PatchProposal?: CoachPatchProposalDtoRaw | null;
+};
+
+// ── Normalizers ─────────────────────────────────────────────────
+
+function normalizeHealth(raw: AiHealthDtoRaw): AiHealthDto {
+ return {
+ provider: pickCase(raw, "provider", "Provider", ""),
+ enabled: pickCase(raw, "enabled", "Enabled", false),
+ healthy: pickCase(raw, "healthy", "Healthy", false),
+ message: pickCase(raw, "message", "Message", ""),
+ };
+}
+
+function normalizeEvidence(raw: CoachEvidenceDtoRaw): CoachEvidenceDto {
+ return {
+ recordId: pickCase(raw, "recordId", "RecordId", null as string | null),
+ text: pickCase(raw, "text", "Text", ""),
+ };
+}
+
+function normalizePatchProposal(
+ raw: CoachPatchProposalDtoRaw | null | undefined,
+): CoachPatchProposalDto | null {
+ if (!raw) return null;
+ return {
+ kind: pickCase(raw, "kind", "Kind", ""),
+ description: pickCase(
+ raw,
+ "description",
+ "Description",
+ null as string | null,
+ ),
+ content: pickCase(raw, "content", "Content", null as string | null),
+ };
+}
+
+function normalizeCoachPlan(raw: CoachPlanDtoRaw): CoachPlanDto {
+ const evidenceRaw = pickCase(
+ raw,
+ "evidence",
+ "Evidence",
+ [] as CoachEvidenceDtoRaw[],
+ );
+ const patchRaw = pickCase(
+ raw,
+ "patchProposal",
+ "PatchProposal",
+ null as CoachPatchProposalDtoRaw | null,
+ );
+
+ return {
+ kind: pickCase(raw, "kind", "Kind", ""),
+ title: pickCase(raw, "title", "Title", ""),
+ summary: pickCase(raw, "summary", "Summary", ""),
+ questions: pickCase(raw, "questions", "Questions", [] as string[]),
+ suggestedNextActions: pickCase(
+ raw,
+ "suggestedNextActions",
+ "SuggestedNextActions",
+ [] as string[],
+ ),
+ suggestedTags: pickCase(
+ raw,
+ "suggestedTags",
+ "SuggestedTags",
+ [] as string[],
+ ),
+ evidence: evidenceRaw.map(normalizeEvidence),
+ patchProposal: normalizePatchProposal(patchRaw),
+ };
+}
+
+// ── API functions ───────────────────────────────────────────────
+
+export async function aiHealth(): Promise {
+ const data = await sendCommand({
+ action: "ai.health",
+ payload: {},
+ });
+ return normalizeHealth(data);
+}
+
+export async function aiChat(prompt: string): Promise {
+ return sendCommand({
+ action: "ai.chat",
+ payload: { prompt },
+ });
+}
+
+export async function aiSummarizeEntry(
+ content: string,
+ fileStem?: string,
+): Promise {
+ return sendCommand({
+ action: "ai.summarize_entry",
+ payload: { content, fileStem },
+ });
+}
+
+export async function coachDaily(
+ payload: CoachSessionPayload = {},
+): Promise {
+ const data = await sendCommand({
+ action: "ai.coach.daily",
+ payload,
+ });
+ return normalizeCoachPlan(data);
+}
+
+export async function coachEvening(
+ payload: CoachSessionPayload = {},
+): Promise {
+ const data = await sendCommand({
+ action: "ai.coach.evening",
+ payload,
+ });
+ return normalizeCoachPlan(data);
+}
+
+export async function coachWeekly(
+ payload: CoachSessionPayload = {},
+): Promise {
+ const data = await sendCommand({
+ action: "ai.coach.weekly",
+ payload,
+ });
+ return normalizeCoachPlan(data);
+}
diff --git a/Journal.App/src/lib/backend/conversations.ts b/Journal.App/src/lib/backend/conversations.ts
new file mode 100644
index 0000000..675ff28
--- /dev/null
+++ b/Journal.App/src/lib/backend/conversations.ts
@@ -0,0 +1,166 @@
+import { sendCommand } from "./client";
+import { pickCase } from "./normalize";
+
+// ── Public types ────────────────────────────────────────────────
+
+export type ConversationDto = {
+ id: string;
+ title: string;
+ createdAt: string;
+ updatedAt: string;
+};
+
+export type ConversationMessageDto = {
+ id: string;
+ role: string;
+ text: string;
+ createdAt: string;
+};
+
+export type ConversationDetailDto = {
+ id: string;
+ title: string;
+ messages: ConversationMessageDto[];
+ createdAt: string;
+ updatedAt: string;
+};
+
+export type ConversationChatResult = {
+ userMessage: ConversationMessageDto;
+ assistantMessage: ConversationMessageDto;
+};
+
+// ── Raw (PascalCase) variants ───────────────────────────────────
+
+type ConversationDtoRaw = {
+ id?: string;
+ title?: string;
+ createdAt?: string;
+ updatedAt?: string;
+ Id?: string;
+ Title?: string;
+ CreatedAt?: string;
+ UpdatedAt?: string;
+};
+
+type ConversationMessageDtoRaw = {
+ id?: string;
+ role?: string;
+ text?: string;
+ createdAt?: string;
+ Id?: string;
+ Role?: string;
+ Text?: string;
+ CreatedAt?: string;
+};
+
+type ConversationDetailDtoRaw = {
+ id?: string;
+ title?: string;
+ messages?: ConversationMessageDtoRaw[];
+ createdAt?: string;
+ updatedAt?: string;
+ Id?: string;
+ Title?: string;
+ Messages?: ConversationMessageDtoRaw[];
+ CreatedAt?: string;
+ UpdatedAt?: string;
+};
+
+type ConversationChatResultRaw = {
+ userMessage?: ConversationMessageDtoRaw;
+ assistantMessage?: ConversationMessageDtoRaw;
+ UserMessage?: ConversationMessageDtoRaw;
+ AssistantMessage?: ConversationMessageDtoRaw;
+};
+
+// ── Normalizers ─────────────────────────────────────────────────
+
+function normalizeMessage(raw: ConversationMessageDtoRaw): ConversationMessageDto {
+ return {
+ id: pickCase(raw, "id", "Id", ""),
+ role: pickCase(raw, "role", "Role", ""),
+ text: pickCase(raw, "text", "Text", ""),
+ createdAt: pickCase(raw, "createdAt", "CreatedAt", ""),
+ };
+}
+
+function normalizeConversation(raw: ConversationDtoRaw): ConversationDto {
+ return {
+ id: pickCase(raw, "id", "Id", ""),
+ title: pickCase(raw, "title", "Title", ""),
+ createdAt: pickCase(raw, "createdAt", "CreatedAt", ""),
+ updatedAt: pickCase(raw, "updatedAt", "UpdatedAt", ""),
+ };
+}
+
+function normalizeDetail(raw: ConversationDetailDtoRaw): ConversationDetailDto {
+ const msgs = pickCase(raw, "messages", "Messages", [] as ConversationMessageDtoRaw[]);
+ return {
+ id: pickCase(raw, "id", "Id", ""),
+ title: pickCase(raw, "title", "Title", ""),
+ messages: msgs.map(normalizeMessage),
+ createdAt: pickCase(raw, "createdAt", "CreatedAt", ""),
+ updatedAt: pickCase(raw, "updatedAt", "UpdatedAt", ""),
+ };
+}
+
+function normalizeChatResult(raw: ConversationChatResultRaw): ConversationChatResult {
+ const userRaw = pickCase(raw, "userMessage", "UserMessage", {} as ConversationMessageDtoRaw);
+ const assistantRaw = pickCase(raw, "assistantMessage", "AssistantMessage", {} as ConversationMessageDtoRaw);
+ return {
+ userMessage: normalizeMessage(userRaw),
+ assistantMessage: normalizeMessage(assistantRaw),
+ };
+}
+
+// ── API functions ───────────────────────────────────────────────
+
+export async function listConversations(): Promise {
+ const data = await sendCommand({
+ action: "conversations.list",
+ payload: {},
+ });
+ return (data ?? []).map(normalizeConversation);
+}
+
+export async function getConversation(id: string): Promise {
+ const data = await sendCommand({
+ action: "conversations.get",
+ id,
+ payload: {},
+ });
+ return normalizeDetail(data);
+}
+
+export async function createConversation(title: string): Promise {
+ const data = await sendCommand({
+ action: "conversations.create",
+ payload: { title },
+ });
+ return normalizeConversation(data);
+}
+
+export async function updateConversation(id: string, title: string): Promise {
+ return sendCommand({
+ action: "conversations.update",
+ id,
+ payload: { title },
+ });
+}
+
+export async function deleteConversation(id: string): Promise {
+ return sendCommand({
+ action: "conversations.delete",
+ id,
+ payload: {},
+ });
+}
+
+export async function conversationChat(conversationId: string, prompt: string): Promise {
+ const data = await sendCommand({
+ action: "conversations.chat",
+ payload: { conversationId, prompt },
+ });
+ return normalizeChatResult(data);
+}
diff --git a/Journal.App/src/lib/components/CoachPanel.svelte b/Journal.App/src/lib/components/CoachPanel.svelte
new file mode 100644
index 0000000..135ff43
--- /dev/null
+++ b/Journal.App/src/lib/components/CoachPanel.svelte
@@ -0,0 +1,691 @@
+
+
+
+
+
+
+ {#if healthChecking}
+
+ Checking AI status…
+ {:else if health}
+
+
+ {health.provider || "AI"} — {health.healthy ? "Ready" : "Unavailable"}
+
+ {#if health.message}
+ {health.message}
+ {/if}
+ {:else}
+
+ AI status unknown
+ {/if}
+
+
+
+ {#if coachBusy}
+
+
+
Running {kindLabels[coachKind] ?? "coach session"}…
+
+ This may take a moment while the model generates a response.
+
+
+ {:else if coachError}
+
+ {:else if coachPlan}
+
+
+
+ {#if coachPlan.summary}
+
+ Summary
+ {coachPlan.summary}
+
+ {/if}
+
+ {#if coachPlan.questions.length > 0}
+
+ Reflection Questions
+
+ {#each coachPlan.questions as question}
+ - {question}
+ {/each}
+
+
+ {/if}
+
+ {#if coachPlan.suggestedNextActions.length > 0}
+
+ Suggested Next Actions
+
+ {#each coachPlan.suggestedNextActions as action}
+ - {action}
+ {/each}
+
+
+ {/if}
+
+ {#if coachPlan.suggestedTags.length > 0}
+
+ Suggested Tags
+
+ {#each coachPlan.suggestedTags as tag}
+ {tag}
+ {/each}
+
+
+ {/if}
+
+ {#if coachPlan.evidence.length > 0}
+
+ Evidence
+
+ {#each coachPlan.evidence as item}
+ -
+ {item.text}
+ {#if item.recordId}
+ ({item.recordId})
+ {/if}
+
+ {/each}
+
+
+ {/if}
+
+ {#if coachPlan.patchProposal}
+
+ Patch Proposal
+
+
{coachPlan.patchProposal.kind}
+ {#if coachPlan.patchProposal.description}
+
{coachPlan.patchProposal.description}
+ {/if}
+ {#if coachPlan.patchProposal.content}
+
{coachPlan.patchProposal.content}
+ {/if}
+
+
+ {/if}
+
+ {:else if chatMessages.length === 0 && !chatBusy}
+
+
psychology
+
+ Select a coaching session from the sidebar, or ask a question using
+ the input below.
+
+
+ {/if}
+
+
+ {#if chatMessages.length > 0}
+
+ Conversation
+
+ {#each chatMessages as msg}
+
+
{msg.role === "user" ? "You" : msg.role === "error" ? "Error" : "AI"}
+
{msg.text}
+
+ {/each}
+ {#if chatBusy}
+
+ {/if}
+
+
+ {:else if chatBusy}
+
+ {/if}
+
+
+
+
+
+
diff --git a/Journal.App/src/lib/components/EditorPanel.svelte b/Journal.App/src/lib/components/EditorPanel.svelte
index e890f27..7f714b8 100644
--- a/Journal.App/src/lib/components/EditorPanel.svelte
+++ b/Journal.App/src/lib/components/EditorPanel.svelte
@@ -4,6 +4,8 @@
import ListEditor from "$lib/components/editor/ListEditor.svelte";
import TodoEditor from "$lib/components/editor/TodoEditor.svelte";
import MarkdownEditor from "$lib/components/editor/MarkdownEditor.svelte";
+ import CoachPanel from "$lib/components/CoachPanel.svelte";
+ import { aiStatusStore, coachStateStore } from "$lib/stores/ai";
export let activeSection = "entries";
export let openDocumentId = "entries/daily-notes";
@@ -154,6 +156,15 @@
{/if}
+ {:else if activeSection === "coach"}
+
{:else if !openDocumentId}
edit_note
diff --git a/Journal.App/src/lib/components/Navbar.svelte b/Journal.App/src/lib/components/Navbar.svelte
index 1141680..f33b41d 100644
--- a/Journal.App/src/lib/components/Navbar.svelte
+++ b/Journal.App/src/lib/components/Navbar.svelte
@@ -15,6 +15,7 @@
{ id: "fragments", label: "Fragments", icon: "auto_stories" },
{ id: "todos", label: "To-Do List", icon: "checklist" },
{ id: "lists", label: "Lists", icon: "lists" },
+ { id: "coach", label: "Coach", icon: "psychology" },
];
function selectItem(id: string) {
diff --git a/Journal.App/src/lib/components/SidePanel.svelte b/Journal.App/src/lib/components/SidePanel.svelte
index 3b9d0c4..79fcc15 100644
--- a/Journal.App/src/lib/components/SidePanel.svelte
+++ b/Journal.App/src/lib/components/SidePanel.svelte
@@ -32,6 +32,24 @@
} from "$lib/stores/todos";
import { vaultUnlocked } from "$lib/stores/session";
import { extractEntryTags } from "$lib/utils/metadata";
+ import {
+ aiStatusStore,
+ coachStateStore,
+ checkAiHealth,
+ runCoachSession,
+ clearCoachPlan,
+ } from "$lib/stores/ai";
+ import type { CoachSessionPayload } from "$lib/backend/ai";
+ import {
+ conversationsStore,
+ activeConversationStore,
+ loadConversations,
+ createNewConversation,
+ openConversation,
+ renameConversation,
+ removeConversation,
+ clearActiveConversation,
+ } from "$lib/stores/conversations";
export let activeSection = "entries";
export let activeDocumentId = "";
@@ -94,8 +112,90 @@
fragments: "Fragments",
todos: "To-Do List",
lists: "Lists",
+ coach: "Coach",
};
+ // ── Coach state ──────────────────────────────────
+ let coachHealthChecked = false;
+ let conversationsLoaded = false;
+ let editingConversationId = "";
+ let editingConversationTitle = "";
+
+ const MAX_CONTEXT_ENTRIES = 5;
+ const MAX_CONTEXT_FRAGMENTS = 10;
+
+ function gatherCoachContext(): CoachSessionPayload {
+ const now = new Date();
+ const dateLocal = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
+ const recentEntries = $entriesStore
+ .filter((e) => e.initialContent?.trim())
+ .slice(0, MAX_CONTEXT_ENTRIES)
+ .map((e) => e.initialContent);
+ const recentFragments = $fragmentsStore
+ .filter((f) => f.initialContent?.trim())
+ .slice(0, MAX_CONTEXT_FRAGMENTS)
+ .map((f) => f.initialContent);
+ return { dateLocal, recentEntries, recentFragments };
+ }
+
+ function handleCoachSession(kind: string) {
+ const payload = gatherCoachContext();
+ if (kind === "weekly") {
+ const now = new Date();
+ const weekDay = (now.getDay() + 6) % 7;
+ const start = new Date(now);
+ start.setDate(now.getDate() - weekDay);
+ const end = new Date(start);
+ end.setDate(start.getDate() + 6);
+ payload.weekStartLocal = `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, "0")}-${String(start.getDate()).padStart(2, "0")}`;
+ payload.weekEndLocal = `${end.getFullYear()}-${String(end.getMonth() + 1).padStart(2, "0")}-${String(end.getDate()).padStart(2, "0")}`;
+ }
+ void runCoachSession(kind, payload);
+ }
+
+ // ── Conversation helpers ──────────────────────────
+ function ensureConversationsLoaded() {
+ if (!conversationsLoaded) {
+ conversationsLoaded = true;
+ void loadConversations();
+ }
+ }
+
+ $: if (activeSection === "coach") {
+ ensureConversationsLoaded();
+ }
+
+ function formatConversationDefaultTitle(): string {
+ const now = new Date();
+ return now.toLocaleString(undefined, {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ });
+ }
+
+ function startRenamingConversation(id: string, currentTitle: string) {
+ editingConversationId = id;
+ editingConversationTitle = currentTitle;
+ }
+
+ function commitRename() {
+ const trimmed = editingConversationTitle.trim();
+ if (trimmed && editingConversationId) {
+ void renameConversation(editingConversationId, trimmed);
+ }
+ editingConversationId = "";
+ editingConversationTitle = "";
+ }
+
+ function cancelRename() {
+ editingConversationId = "";
+ editingConversationTitle = "";
+ }
+
+
const today = new Date();
let calendarYear = today.getFullYear();
let calendarMonth = today.getMonth();
@@ -938,15 +1038,17 @@
calendar_month
{/if}
-
+ {#if activeSection !== "coach"}
+
+ {/if}
@@ -1085,6 +1187,145 @@
Last refreshed: {calendarLastRefreshedAt || "Not yet"}
+ {:else if activeSection === "coach"}
+
{:else}
search
@@ -1597,4 +1838,166 @@
grid-template-columns: 1fr;
}
}
+
+ /* ── Coach sidebar ────────────────────────────────── */
+
+ .coach-sidebar {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ overflow: auto;
+ min-height: 0;
+ }
+
+ .coach-health {
+ display: flex;
+ }
+
+ .coach-health-btn {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ padding: 8px 12px;
+ border-radius: 8px;
+ border: 1px solid var(--border-soft);
+ background: var(--surface-1);
+ color: var(--text-muted);
+ font-size: 0.8rem;
+ cursor: pointer;
+
+ &:hover {
+ background: var(--bg-hover);
+ border-color: var(--border-strong);
+ color: var(--text-primary);
+ }
+
+ &:disabled {
+ opacity: 0.6;
+ cursor: default;
+ }
+ }
+
+ .health-indicator {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ background: var(--text-dim);
+
+ &.healthy {
+ background: #4ade80;
+ }
+
+ &.unhealthy {
+ background: #e06c75;
+ }
+
+ &.checking {
+ animation: coach-pulse 1s ease-in-out infinite;
+ }
+ }
+
+ .coach-session-buttons {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ }
+
+ .coach-session-btn {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ padding: 9px 12px;
+ border-radius: 8px;
+ border: 1px solid var(--border-soft);
+ background: var(--surface-1);
+ color: var(--text-muted);
+ font-size: 0.82rem;
+ cursor: pointer;
+ text-align: left;
+
+ .material-symbols-outlined {
+ font-size: 1rem;
+ }
+
+ &:hover {
+ background: var(--bg-hover);
+ border-color: var(--border-strong);
+ color: var(--text-primary);
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: default;
+ }
+ }
+
+ .coach-clear-btn {
+ align-self: flex-start;
+ padding: 4px 10px;
+ border-radius: 6px;
+ border: 1px solid var(--border-soft);
+ background: transparent;
+ color: var(--text-dim);
+ font-size: 0.74rem;
+ cursor: pointer;
+
+ &:hover {
+ color: var(--text-primary);
+ border-color: var(--border-strong);
+ background: var(--bg-hover);
+ }
+ }
+
+ .subsection-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ .subsection-action {
+ width: 26px;
+ height: 26px;
+ display: grid;
+ place-items: center;
+ border-radius: 6px;
+ border: 1px solid transparent;
+ background: transparent;
+ color: var(--text-dim);
+ cursor: pointer;
+
+ .material-symbols-outlined {
+ font-size: 1rem;
+ }
+
+ &:hover {
+ color: var(--text-primary);
+ border-color: var(--border-soft);
+ background: var(--bg-hover);
+ }
+ }
+
+ .rename-input {
+ width: 100%;
+ font-size: 0.82rem;
+ color: var(--text-primary);
+ background: var(--surface-1);
+ border: 1px solid var(--accent, #6b8afd);
+ border-radius: 6px;
+ padding: 6px 9px;
+ outline: none;
+ }
+
+
+ @keyframes coach-pulse {
+ 0%,
+ 100% {
+ opacity: 0.4;
+ }
+ 50% {
+ opacity: 1;
+ }
+ }
diff --git a/Journal.App/src/lib/stores/ai.ts b/Journal.App/src/lib/stores/ai.ts
new file mode 100644
index 0000000..b8f2135
--- /dev/null
+++ b/Journal.App/src/lib/stores/ai.ts
@@ -0,0 +1,140 @@
+import { writable } from "svelte/store";
+import {
+ aiHealth as aiHealthCommand,
+ aiChat as aiChatCommand,
+ coachDaily,
+ coachEvening,
+ coachWeekly,
+ type AiHealthDto,
+ type CoachPlanDto,
+ type CoachSessionPayload,
+} from "$lib/backend/ai";
+
+// ── Store shapes ────────────────────────────────────────────────
+
+type AiStatusState = {
+ checking: boolean;
+ health: AiHealthDto | null;
+};
+
+type CoachState = {
+ busy: boolean;
+ error: string;
+ plan: CoachPlanDto | null;
+ kind: string;
+};
+
+export type ChatMessage = {
+ role: "user" | "assistant" | "error";
+ text: string;
+};
+
+type ChatState = {
+ busy: boolean;
+ messages: ChatMessage[];
+};
+
+// ── Stores ──────────────────────────────────────────────────────
+
+export const aiStatusStore = writable
({
+ checking: false,
+ health: null,
+});
+
+export const coachStateStore = writable({
+ busy: false,
+ error: "",
+ plan: null,
+ kind: "",
+});
+
+export const chatStateStore = writable({
+ busy: false,
+ messages: [],
+});
+
+// ── Actions ─────────────────────────────────────────────────────
+
+export async function checkAiHealth(): Promise {
+ aiStatusStore.update((s) => ({ ...s, checking: true }));
+ try {
+ const health = await aiHealthCommand();
+ aiStatusStore.set({ checking: false, health });
+ } catch (error) {
+ aiStatusStore.set({
+ checking: false,
+ health: {
+ provider: "",
+ enabled: false,
+ healthy: false,
+ message: error instanceof Error ? error.message : String(error),
+ },
+ });
+ }
+}
+
+const sessionRunners: Record<
+ string,
+ (payload: CoachSessionPayload) => Promise
+> = {
+ daily: coachDaily,
+ evening: coachEvening,
+ weekly: coachWeekly,
+};
+
+export async function runCoachSession(
+ kind: string,
+ payload: CoachSessionPayload = {},
+): Promise {
+ const runner = sessionRunners[kind];
+ if (!runner) {
+ coachStateStore.set({
+ busy: false,
+ error: `Unknown coach session kind: ${kind}`,
+ plan: null,
+ kind,
+ });
+ return;
+ }
+
+ coachStateStore.set({ busy: true, error: "", plan: null, kind });
+ try {
+ const plan = await runner(payload);
+ coachStateStore.set({ busy: false, error: "", plan, kind });
+ } catch (error) {
+ coachStateStore.set({
+ busy: false,
+ error: error instanceof Error ? error.message : String(error),
+ plan: null,
+ kind,
+ });
+ }
+}
+
+export async function runAiChat(prompt: string): Promise {
+ chatStateStore.update((s) => ({
+ busy: true,
+ messages: [...s.messages, { role: "user", text: prompt }],
+ }));
+ try {
+ const response = await aiChatCommand(prompt);
+ chatStateStore.update((s) => ({
+ busy: false,
+ messages: [...s.messages, { role: "assistant", text: response }],
+ }));
+ } catch (error) {
+ const msg = error instanceof Error ? error.message : String(error);
+ chatStateStore.update((s) => ({
+ busy: false,
+ messages: [...s.messages, { role: "error", text: msg }],
+ }));
+ }
+}
+
+export function clearCoachPlan(): void {
+ coachStateStore.set({ busy: false, error: "", plan: null, kind: "" });
+}
+
+export function clearChat(): void {
+ chatStateStore.set({ busy: false, messages: [] });
+}
diff --git a/Journal.App/src/lib/stores/conversations.ts b/Journal.App/src/lib/stores/conversations.ts
new file mode 100644
index 0000000..33d6377
--- /dev/null
+++ b/Journal.App/src/lib/stores/conversations.ts
@@ -0,0 +1,222 @@
+import { writable, get } from "svelte/store";
+import {
+ listConversations as listConversationsApi,
+ getConversation as getConversationApi,
+ createConversation as createConversationApi,
+ deleteConversation as deleteConversationApi,
+ updateConversation as updateConversationApi,
+ conversationChat as conversationChatApi,
+ type ConversationDto,
+ type ConversationMessageDto,
+} from "$lib/backend/conversations";
+
+// ── Store shapes ────────────────────────────────────────────────
+
+type ConversationsState = {
+ items: ConversationDto[];
+ busy: boolean;
+ error: string;
+};
+
+type ActiveConversationState = {
+ id: string;
+ title: string;
+ messages: ConversationMessageDto[];
+ busy: boolean;
+ error: string;
+};
+
+// ── Stores ──────────────────────────────────────────────────────
+
+export const conversationsStore = writable({
+ items: [],
+ busy: false,
+ error: "",
+});
+
+export const activeConversationStore = writable({
+ id: "",
+ title: "",
+ messages: [],
+ busy: false,
+ error: "",
+});
+
+// ── Actions ─────────────────────────────────────────────────────
+
+export async function loadConversations(): Promise {
+ conversationsStore.update((s) => ({ ...s, busy: true, error: "" }));
+ try {
+ const items = await listConversationsApi();
+ conversationsStore.set({ items, busy: false, error: "" });
+ } catch (error) {
+ conversationsStore.update((s) => ({
+ ...s,
+ busy: false,
+ error: error instanceof Error ? error.message : String(error),
+ }));
+ }
+}
+
+export async function createNewConversation(
+ title: string,
+): Promise {
+ try {
+ const conv = await createConversationApi(title);
+ conversationsStore.update((s) => ({
+ ...s,
+ items: [conv, ...s.items],
+ }));
+ // Auto-open the new conversation
+ activeConversationStore.set({
+ id: conv.id,
+ title: conv.title,
+ messages: [],
+ busy: false,
+ error: "",
+ });
+ return conv.id;
+ } catch (error) {
+ conversationsStore.update((s) => ({
+ ...s,
+ error: error instanceof Error ? error.message : String(error),
+ }));
+ return null;
+ }
+}
+
+export async function openConversation(id: string): Promise {
+ activeConversationStore.update((s) => ({
+ ...s,
+ id,
+ busy: true,
+ error: "",
+ }));
+ try {
+ const detail = await getConversationApi(id);
+ activeConversationStore.set({
+ id: detail.id,
+ title: detail.title,
+ messages: detail.messages,
+ busy: false,
+ error: "",
+ });
+ } catch (error) {
+ activeConversationStore.update((s) => ({
+ ...s,
+ busy: false,
+ error: error instanceof Error ? error.message : String(error),
+ }));
+ }
+}
+
+export async function sendConversationMessage(
+ prompt: string,
+): Promise {
+ const state = get(activeConversationStore);
+ if (!state.id) {
+ // Auto-create a conversation from the first message
+ const title =
+ prompt.length > 40 ? prompt.slice(0, 37) + "..." : prompt;
+ const convId = await createNewConversation(title);
+ if (!convId) return;
+ }
+
+ const currentId = get(activeConversationStore).id;
+ if (!currentId) return;
+
+ // Optimistically add user message
+ const tempUserMsg: ConversationMessageDto = {
+ id: `temp-${Date.now()}`,
+ role: "user",
+ text: prompt,
+ createdAt: new Date().toISOString(),
+ };
+
+ activeConversationStore.update((s) => ({
+ ...s,
+ messages: [...s.messages, tempUserMsg],
+ busy: true,
+ error: "",
+ }));
+
+ try {
+ const result = await conversationChatApi(currentId, prompt);
+ activeConversationStore.update((s) => ({
+ ...s,
+ busy: false,
+ // Replace temp user message + add assistant message
+ messages: [
+ ...s.messages.filter((m) => m.id !== tempUserMsg.id),
+ result.userMessage,
+ result.assistantMessage,
+ ],
+ }));
+ // Update conversation list (updated_at changes)
+ void loadConversations();
+ } catch (error) {
+ const errorMsg: ConversationMessageDto = {
+ id: `error-${Date.now()}`,
+ role: "error",
+ text: error instanceof Error ? error.message : String(error),
+ createdAt: new Date().toISOString(),
+ };
+ activeConversationStore.update((s) => ({
+ ...s,
+ busy: false,
+ messages: [...s.messages, errorMsg],
+ }));
+ }
+}
+
+export async function renameConversation(
+ id: string,
+ title: string,
+): Promise {
+ try {
+ await updateConversationApi(id, title);
+ conversationsStore.update((s) => ({
+ ...s,
+ items: s.items.map((c) => (c.id === id ? { ...c, title } : c)),
+ }));
+ const active = get(activeConversationStore);
+ if (active.id === id) {
+ activeConversationStore.update((s) => ({ ...s, title }));
+ }
+ } catch (error) {
+ conversationsStore.update((s) => ({
+ ...s,
+ error: error instanceof Error ? error.message : String(error),
+ }));
+ }
+}
+
+export async function removeConversation(id: string): Promise {
+ try {
+ await deleteConversationApi(id);
+ conversationsStore.update((s) => ({
+ ...s,
+ items: s.items.filter((c) => c.id !== id),
+ }));
+ // If this was the active conversation, clear it
+ const active = get(activeConversationStore);
+ if (active.id === id) {
+ clearActiveConversation();
+ }
+ } catch (error) {
+ conversationsStore.update((s) => ({
+ ...s,
+ error: error instanceof Error ? error.message : String(error),
+ }));
+ }
+}
+
+export function clearActiveConversation(): void {
+ activeConversationStore.set({
+ id: "",
+ title: "",
+ messages: [],
+ busy: false,
+ error: "",
+ });
+}
diff --git a/Journal.App/src/lib/stores/settings.ts b/Journal.App/src/lib/stores/settings.ts
index bf4174c..a96b9c7 100644
--- a/Journal.App/src/lib/stores/settings.ts
+++ b/Journal.App/src/lib/stores/settings.ts
@@ -10,6 +10,7 @@ const startupViews = [
"fragments",
"todos",
"lists",
+ "coach",
] as const;
const defaultStartupView = "entries";
export type StartupView = (typeof startupViews)[number];
diff --git a/Journal.App/src/routes/+page.svelte b/Journal.App/src/routes/+page.svelte
index ee1470a..1b45696 100644
--- a/Journal.App/src/routes/+page.svelte
+++ b/Journal.App/src/routes/+page.svelte
@@ -109,8 +109,8 @@
case "calendar":
case "fragments":
case "todos":
- case "lists":
case "entries":
+ case "coach":
return value;
default:
return "entries";
@@ -126,6 +126,7 @@
case "fragments":
case "todos":
case "lists":
+ case "coach":
return normalized;
default:
return null;
diff --git a/Journal.Core/Dtos/CoachDtos.cs b/Journal.Core/Dtos/CoachDtos.cs
new file mode 100644
index 0000000..7fd4235
--- /dev/null
+++ b/Journal.Core/Dtos/CoachDtos.cs
@@ -0,0 +1,32 @@
+namespace Journal.Core.Dtos;
+
+public sealed record CoachPlanDto(
+ string Kind,
+ string Title,
+ string Summary,
+ IReadOnlyList Questions,
+ IReadOnlyList SuggestedNextActions,
+ IReadOnlyList SuggestedTags,
+ IReadOnlyList Evidence,
+ CoachPatchProposalDto? PatchProposal = null);
+
+public sealed record CoachEvidenceDto(
+ string? RecordId,
+ string Text);
+
+public sealed record CoachPatchProposalDto(
+ string Kind,
+ string? Description = null,
+ string? Content = null);
+
+public sealed record CoachContextDto(
+ string DateLocal,
+ string? WeekStartLocal = null,
+ string? WeekEndLocal = null,
+ IReadOnlyList? RecentEntries = null,
+ IReadOnlyList? RecentFragments = null,
+ CoachPreferencesDto? Preferences = null);
+
+public sealed record CoachPreferencesDto(
+ int MaxQuestions = 3,
+ int MaxNextActions = 3);
diff --git a/Journal.Core/Dtos/CommandDtos.cs b/Journal.Core/Dtos/CommandDtos.cs
index 03b6825..e979bfc 100644
--- a/Journal.Core/Dtos/CommandDtos.cs
+++ b/Journal.Core/Dtos/CommandDtos.cs
@@ -20,6 +20,9 @@ internal sealed record AiSummarizeEntryPayload(string Content, string? FileStem
internal sealed record AiSummarizeAllPayload(List? Entries);
internal sealed record AiChatPayload(string Prompt);
internal sealed record AiEmbedPayload(string Content);
+internal sealed record CoachDailyPayload(string? DateLocal = null, CoachPreferencesDto? Preferences = null, List? RecentEntries = null, List? RecentFragments = null);
+internal sealed record CoachEveningPayload(string? DateLocal = null, CoachPreferencesDto? Preferences = null, List? RecentEntries = null, List? RecentFragments = null);
+internal sealed record CoachWeeklyPayload(string? WeekStartLocal = null, string? WeekEndLocal = null, CoachPreferencesDto? Preferences = null, List? RecentEntries = null, List? RecentFragments = null);
internal sealed record SpeechTranscribePayload(
string? AudioBase64 = null,
string? Audio_Base64 = null,
@@ -30,6 +33,9 @@ internal sealed record SpeechTranscribePayload(
int? SimulateDelayMs = null,
int? Simulate_Delay_Ms = null);
internal sealed record S2TPollPayload(int? MaxItems = null);
+internal sealed record ConversationCreatePayload(string Title);
+internal sealed record ConversationUpdatePayload(string? Title);
+internal sealed record ConversationChatPayload(string ConversationId, string Prompt);
internal sealed record SearchEntriesPayload(
string? Query = null,
string? Section = null,
diff --git a/Journal.Core/Dtos/ConversationDtos.cs b/Journal.Core/Dtos/ConversationDtos.cs
new file mode 100644
index 0000000..30b4aee
--- /dev/null
+++ b/Journal.Core/Dtos/ConversationDtos.cs
@@ -0,0 +1,28 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Journal.Core.Dtos;
+
+public sealed record ConversationDto(
+ Guid Id,
+ string Title,
+ DateTimeOffset CreatedAt,
+ DateTimeOffset UpdatedAt);
+
+public sealed record ConversationDetailDto(
+ Guid Id,
+ string Title,
+ IReadOnlyList Messages,
+ DateTimeOffset CreatedAt,
+ DateTimeOffset UpdatedAt);
+
+public sealed record ConversationMessageDto(
+ Guid Id,
+ string Role,
+ string Text,
+ DateTimeOffset CreatedAt);
+
+public sealed record CreateConversationDto(
+ [Required] string Title);
+
+public sealed record UpdateConversationDto(
+ string? Title);
diff --git a/Journal.Core/Entry.cs b/Journal.Core/Entry.cs
index 8b0e6c1..bfc425d 100644
--- a/Journal.Core/Entry.cs
+++ b/Journal.Core/Entry.cs
@@ -9,6 +9,7 @@ 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;
@@ -29,6 +30,8 @@ public class Entry(
IEntryFileService entryFiles,
IListService lists,
ITodoService todos,
+ ICoachService coach,
+ IConversationService conversations,
CommandLogger logger)
{
private readonly IFragmentService _fragments = fragments;
@@ -43,6 +46,8 @@ public class Entry(
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 VaultSyncActions = new(StringComparer.Ordinal)
{
@@ -61,7 +66,11 @@ public class Entry(
"todos.delete",
"todos.items.create",
"todos.items.update",
- "todos.items.delete"
+ "todos.items.delete",
+ "conversations.create",
+ "conversations.update",
+ "conversations.delete",
+ "conversations.chat"
};
private static readonly JsonSerializerOptions JsonOptions = new()
{
@@ -303,6 +312,84 @@ public class Entry(
return Error("Missing or invalid payload");
result = await _ai.EmbedAsync(embedPayload.Content);
break;
+
+ // ── Coach ─────────────────────────────────────────
+ case "ai.coach.daily":
+ var coachDailyPayload = DeserializePayload(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(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(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(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(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(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;
diff --git a/Journal.Core/Models/Conversation.cs b/Journal.Core/Models/Conversation.cs
new file mode 100644
index 0000000..bd3f652
--- /dev/null
+++ b/Journal.Core/Models/Conversation.cs
@@ -0,0 +1,33 @@
+namespace Journal.Core.Models;
+
+public class Conversation
+{
+ public Guid Id { get; }
+ public string Title { get; set; }
+ public DateTimeOffset CreatedAt { get; set; }
+ public DateTimeOffset UpdatedAt { get; set; }
+
+ public Conversation(string title)
+ {
+ if (string.IsNullOrWhiteSpace(title))
+ throw new ArgumentException("Title is required", nameof(title));
+
+ Id = Guid.NewGuid();
+ Title = title.Trim();
+ CreatedAt = DateTimeOffset.Now;
+ UpdatedAt = CreatedAt;
+ }
+
+ public Conversation(Guid id, string title, DateTimeOffset createdAt, DateTimeOffset updatedAt)
+ {
+ if (id == Guid.Empty)
+ throw new ArgumentException("Id is required", nameof(id));
+ if (string.IsNullOrWhiteSpace(title))
+ throw new ArgumentException("Title is required", nameof(title));
+
+ Id = id;
+ Title = title.Trim();
+ CreatedAt = createdAt;
+ UpdatedAt = updatedAt;
+ }
+}
diff --git a/Journal.Core/Models/ConversationMessage.cs b/Journal.Core/Models/ConversationMessage.cs
new file mode 100644
index 0000000..36c6bba
--- /dev/null
+++ b/Journal.Core/Models/ConversationMessage.cs
@@ -0,0 +1,38 @@
+namespace Journal.Core.Models;
+
+public class ConversationMessage
+{
+ public Guid Id { get; }
+ public Guid ConversationId { get; }
+ public string Role { get; set; }
+ public string Text { get; set; }
+ public DateTimeOffset CreatedAt { get; set; }
+
+ public ConversationMessage(Guid conversationId, string role, string text)
+ {
+ if (conversationId == Guid.Empty)
+ throw new ArgumentException("ConversationId is required", nameof(conversationId));
+ if (string.IsNullOrWhiteSpace(role))
+ throw new ArgumentException("Role is required", nameof(role));
+ if (string.IsNullOrWhiteSpace(text))
+ throw new ArgumentException("Text is required", nameof(text));
+
+ Id = Guid.NewGuid();
+ ConversationId = conversationId;
+ Role = role.Trim();
+ Text = text;
+ CreatedAt = DateTimeOffset.Now;
+ }
+
+ public ConversationMessage(Guid id, Guid conversationId, string role, string text, DateTimeOffset createdAt)
+ {
+ if (id == Guid.Empty)
+ throw new ArgumentException("Id is required", nameof(id));
+
+ Id = id;
+ ConversationId = conversationId;
+ Role = role;
+ Text = text;
+ CreatedAt = createdAt;
+ }
+}
diff --git a/Journal.Core/Models/JournalConfig.cs b/Journal.Core/Models/JournalConfig.cs
index 3f81da5..7abcae6 100644
--- a/Journal.Core/Models/JournalConfig.cs
+++ b/Journal.Core/Models/JournalConfig.cs
@@ -24,4 +24,5 @@ public sealed record JournalConfig(
string AiProvider,
string PythonExecutable,
string PythonAiSidecarPath,
- int AiSidecarTimeoutMs);
+ int AiSidecarTimeoutMs,
+ string GgufModelPath);
diff --git a/Journal.Core/Repositories/IConversationRepository.cs b/Journal.Core/Repositories/IConversationRepository.cs
new file mode 100644
index 0000000..271ecb9
--- /dev/null
+++ b/Journal.Core/Repositories/IConversationRepository.cs
@@ -0,0 +1,14 @@
+using Journal.Core.Models;
+
+namespace Journal.Core.Repositories;
+
+public interface IConversationRepository
+{
+ List GetAll();
+ Conversation? GetById(Guid id);
+ void Add(Conversation conversation);
+ bool Update(Guid id, string? title = null);
+ bool Remove(Guid id);
+ void AddMessage(ConversationMessage message);
+ List GetMessages(Guid conversationId);
+}
diff --git a/Journal.Core/Repositories/SqliteConversationRepository.cs b/Journal.Core/Repositories/SqliteConversationRepository.cs
new file mode 100644
index 0000000..ab85675
--- /dev/null
+++ b/Journal.Core/Repositories/SqliteConversationRepository.cs
@@ -0,0 +1,217 @@
+using Journal.Core.Models;
+using Journal.Core.Services.Database;
+using Microsoft.Data.Sqlite;
+
+namespace Journal.Core.Repositories;
+
+public sealed class SqliteConversationRepository(IDatabaseSessionService session) : IConversationRepository
+{
+ private readonly IDatabaseSessionService _session = session;
+
+ public List GetAll()
+ {
+ var conn = _session.GetConnection();
+ return ReadAll(conn);
+ }
+
+ public Conversation? GetById(Guid id)
+ {
+ var conn = _session.GetConnection();
+ return ReadById(conn, id);
+ }
+
+ public void Add(Conversation conversation)
+ {
+ ArgumentNullException.ThrowIfNull(conversation);
+ var conn = _session.GetConnection();
+ Insert(conn, conversation);
+ }
+
+ public bool Update(Guid id, string? title = null)
+ {
+ var conn = _session.GetConnection();
+ var existing = ReadById(conn, id);
+ if (existing is null)
+ return false;
+
+ if (title is not null)
+ {
+ if (string.IsNullOrWhiteSpace(title))
+ throw new ArgumentException("Title cannot be empty", nameof(title));
+ existing.Title = title.Trim();
+ }
+
+ existing.UpdatedAt = DateTimeOffset.Now;
+ UpdateRow(conn, existing);
+ return true;
+ }
+
+ public bool Remove(Guid id)
+ {
+ var conn = _session.GetConnection();
+ return Delete(conn, id);
+ }
+
+ public void AddMessage(ConversationMessage message)
+ {
+ ArgumentNullException.ThrowIfNull(message);
+ var conn = _session.GetConnection();
+ InsertMessage(conn, message);
+
+ // Touch conversation updated_at
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "UPDATE conversations SET updated_at = @now WHERE guid = @guid;";
+ cmd.Parameters.AddWithValue("@now", DateTimeOffset.Now.ToString("O"));
+ cmd.Parameters.AddWithValue("@guid", message.ConversationId.ToString("D"));
+ cmd.ExecuteNonQuery();
+ }
+
+ public List GetMessages(Guid conversationId)
+ {
+ var conn = _session.GetConnection();
+ return ReadMessages(conn, conversationId);
+ }
+
+ // ── Private helpers ──────────────────────────────────────────────
+
+ private static void Insert(SqliteConnection conn, Conversation c)
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = """
+ INSERT INTO conversations (guid, title, created_at, updated_at)
+ VALUES (@guid, @title, @createdAt, @updatedAt);
+ """;
+ cmd.Parameters.AddWithValue("@guid", c.Id.ToString("D"));
+ cmd.Parameters.AddWithValue("@title", c.Title);
+ cmd.Parameters.AddWithValue("@createdAt", c.CreatedAt.ToString("O"));
+ cmd.Parameters.AddWithValue("@updatedAt", c.UpdatedAt.ToString("O"));
+ cmd.ExecuteNonQuery();
+ }
+
+ private static void UpdateRow(SqliteConnection conn, Conversation c)
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = """
+ UPDATE conversations SET title = @title, updated_at = @updatedAt
+ WHERE guid = @guid;
+ """;
+ cmd.Parameters.AddWithValue("@guid", c.Id.ToString("D"));
+ cmd.Parameters.AddWithValue("@title", c.Title);
+ cmd.Parameters.AddWithValue("@updatedAt", c.UpdatedAt.ToString("O"));
+ cmd.ExecuteNonQuery();
+ }
+
+ private static bool Delete(SqliteConnection conn, Guid id)
+ {
+ using var tx = conn.BeginTransaction();
+
+ // Get the row id for cascade delete of messages
+ var rowId = GetRowId(conn, id);
+ if (rowId.HasValue)
+ {
+ using var delMsgs = conn.CreateCommand();
+ delMsgs.CommandText = "DELETE FROM conversation_messages WHERE conversation_id = @id;";
+ delMsgs.Parameters.AddWithValue("@id", rowId.Value);
+ delMsgs.ExecuteNonQuery();
+ }
+
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "DELETE FROM conversations WHERE guid = @guid;";
+ cmd.Parameters.AddWithValue("@guid", id.ToString("D"));
+ var rows = cmd.ExecuteNonQuery();
+
+ tx.Commit();
+ return rows > 0;
+ }
+
+ private static long? GetRowId(SqliteConnection conn, Guid id)
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT id FROM conversations WHERE guid = @guid;";
+ cmd.Parameters.AddWithValue("@guid", id.ToString("D"));
+ var result = cmd.ExecuteScalar();
+ return result is long rowId ? rowId : null;
+ }
+
+ private static Conversation? ReadById(SqliteConnection conn, Guid id)
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT guid, title, created_at, updated_at FROM conversations WHERE guid = @guid;";
+ cmd.Parameters.AddWithValue("@guid", id.ToString("D"));
+
+ using var reader = cmd.ExecuteReader();
+ return reader.Read() ? MapConversation(reader) : null;
+ }
+
+ private static List ReadAll(SqliteConnection conn)
+ {
+ var results = new List();
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT guid, title, created_at, updated_at FROM conversations ORDER BY updated_at DESC;";
+
+ using var reader = cmd.ExecuteReader();
+ while (reader.Read())
+ results.Add(MapConversation(reader));
+
+ return results;
+ }
+
+ private static void InsertMessage(SqliteConnection conn, ConversationMessage m)
+ {
+ var convRowId = GetRowId(conn, m.ConversationId)
+ ?? throw new InvalidOperationException($"Conversation {m.ConversationId} not found");
+
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = """
+ INSERT INTO conversation_messages (guid, conversation_id, role, text, created_at)
+ VALUES (@guid, @conversationId, @role, @text, @createdAt);
+ """;
+ cmd.Parameters.AddWithValue("@guid", m.Id.ToString("D"));
+ cmd.Parameters.AddWithValue("@conversationId", convRowId);
+ cmd.Parameters.AddWithValue("@role", m.Role);
+ cmd.Parameters.AddWithValue("@text", m.Text);
+ cmd.Parameters.AddWithValue("@createdAt", m.CreatedAt.ToString("O"));
+ cmd.ExecuteNonQuery();
+ }
+
+ private static List ReadMessages(SqliteConnection conn, Guid conversationId)
+ {
+ var rowId = GetRowId(conn, conversationId);
+ if (!rowId.HasValue)
+ return [];
+
+ var results = new List();
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = """
+ SELECT guid, role, text, created_at
+ FROM conversation_messages
+ WHERE conversation_id = @conversationId
+ ORDER BY created_at ASC;
+ """;
+ cmd.Parameters.AddWithValue("@conversationId", rowId.Value);
+
+ using var reader = cmd.ExecuteReader();
+ while (reader.Read())
+ results.Add(MapMessage(reader, conversationId));
+
+ return results;
+ }
+
+ private static Conversation MapConversation(SqliteDataReader reader)
+ {
+ var guid = Guid.Parse(reader.GetString(0));
+ var title = reader.GetString(1);
+ var createdAt = reader.IsDBNull(2) ? DateTimeOffset.MinValue : DateTimeOffset.Parse(reader.GetString(2));
+ var updatedAt = reader.IsDBNull(3) ? createdAt : DateTimeOffset.Parse(reader.GetString(3));
+ return new Conversation(guid, title, createdAt, updatedAt);
+ }
+
+ private static ConversationMessage MapMessage(SqliteDataReader reader, Guid conversationId)
+ {
+ var guid = Guid.Parse(reader.GetString(0));
+ var role = reader.GetString(1);
+ var text = reader.IsDBNull(2) ? "" : reader.GetString(2);
+ var createdAt = reader.IsDBNull(3) ? DateTimeOffset.MinValue : DateTimeOffset.Parse(reader.GetString(3));
+ return new ConversationMessage(guid, conversationId, role, text, createdAt);
+ }
+}
diff --git a/Journal.Core/ServiceCollectionExtensions.cs b/Journal.Core/ServiceCollectionExtensions.cs
index 1db977f..852cd18 100644
--- a/Journal.Core/ServiceCollectionExtensions.cs
+++ b/Journal.Core/ServiceCollectionExtensions.cs
@@ -9,6 +9,7 @@ using Journal.Core.Services.Lists;
using Journal.Core.Services.Logging;
using Journal.Core.Services.Sidecar;
using Journal.Core.Services.Speech;
+using Journal.Core.Services.Conversations;
using Journal.Core.Services.Todos;
using Journal.Core.Services.Vault;
@@ -65,6 +66,9 @@ public static class ServiceCollectionExtensions
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton(new DisabledCoachService());
+ services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
return services;
diff --git a/Journal.Core/Services/Ai/DisabledAiService.cs b/Journal.Core/Services/Ai/DisabledAiService.cs
index d5a7ad6..c44443e 100644
--- a/Journal.Core/Services/Ai/DisabledAiService.cs
+++ b/Journal.Core/Services/Ai/DisabledAiService.cs
@@ -20,6 +20,9 @@ public sealed class DisabledAiService(string provider, string message = "AI prov
public Task ChatAsync(string prompt, CancellationToken cancellationToken = default) =>
Task.FromResult(_message);
+ public Task ChatWithHistoryAsync(IReadOnlyList<(string Role, string Text)> history, string prompt, CancellationToken cancellationToken = default) =>
+ Task.FromResult(_message);
+
public Task> EmbedAsync(string content, CancellationToken cancellationToken = default) =>
Task.FromResult>([]);
}
diff --git a/Journal.Core/Services/Ai/DisabledCoachService.cs b/Journal.Core/Services/Ai/DisabledCoachService.cs
new file mode 100644
index 0000000..6b90fb9
--- /dev/null
+++ b/Journal.Core/Services/Ai/DisabledCoachService.cs
@@ -0,0 +1,26 @@
+using Journal.Core.Dtos;
+
+namespace Journal.Core.Services.Ai;
+
+public sealed class DisabledCoachService(string message = "Coach is not available. Set JOURNAL_AI_PROVIDER to enable.") : ICoachService
+{
+ private readonly string _message = message;
+
+ public Task DailyCheckInAsync(CoachContextDto context, CancellationToken cancellationToken = default)
+ => Task.FromResult(Disabled("daily_checkin"));
+
+ public Task EveningReviewAsync(CoachContextDto context, CancellationToken cancellationToken = default)
+ => Task.FromResult(Disabled("evening_review"));
+
+ public Task WeeklyReviewAsync(CoachContextDto context, CancellationToken cancellationToken = default)
+ => Task.FromResult(Disabled("weekly_review"));
+
+ private CoachPlanDto Disabled(string kind) => new(
+ Kind: kind,
+ Title: "Coach Disabled",
+ Summary: _message,
+ Questions: [],
+ SuggestedNextActions: [],
+ SuggestedTags: [],
+ Evidence: []);
+}
diff --git a/Journal.Core/Services/Ai/IAiService.cs b/Journal.Core/Services/Ai/IAiService.cs
index 5300641..0621e98 100644
--- a/Journal.Core/Services/Ai/IAiService.cs
+++ b/Journal.Core/Services/Ai/IAiService.cs
@@ -8,5 +8,6 @@ public interface IAiService
Task SummarizeEntryAsync(string content, string? fileStem = null, CancellationToken cancellationToken = default);
Task SummarizeAllAsync(IReadOnlyList entries, CancellationToken cancellationToken = default);
Task ChatAsync(string prompt, CancellationToken cancellationToken = default);
+ Task ChatWithHistoryAsync(IReadOnlyList<(string Role, string Text)> history, string prompt, CancellationToken cancellationToken = default);
Task> EmbedAsync(string content, CancellationToken cancellationToken = default);
}
diff --git a/Journal.Core/Services/Ai/ICoachService.cs b/Journal.Core/Services/Ai/ICoachService.cs
new file mode 100644
index 0000000..e2a63d4
--- /dev/null
+++ b/Journal.Core/Services/Ai/ICoachService.cs
@@ -0,0 +1,10 @@
+using Journal.Core.Dtos;
+
+namespace Journal.Core.Services.Ai;
+
+public interface ICoachService
+{
+ Task DailyCheckInAsync(CoachContextDto context, CancellationToken cancellationToken = default);
+ Task EveningReviewAsync(CoachContextDto context, CancellationToken cancellationToken = default);
+ Task WeeklyReviewAsync(CoachContextDto context, CancellationToken cancellationToken = default);
+}
diff --git a/Journal.Core/Services/Ai/PythonSidecarAiService.cs b/Journal.Core/Services/Ai/PythonSidecarAiService.cs
index daba179..b1c0474 100644
--- a/Journal.Core/Services/Ai/PythonSidecarAiService.cs
+++ b/Journal.Core/Services/Ai/PythonSidecarAiService.cs
@@ -61,6 +61,13 @@ public sealed class PythonSidecarAiService : IAiService
return data?.GetString() ?? "";
}
+ public Task ChatWithHistoryAsync(IReadOnlyList<(string Role, string Text)> history,
+ string prompt, CancellationToken cancellationToken = default)
+ {
+ // Python sidecar does not support multi-turn — fall back to single-turn
+ return ChatAsync(prompt, cancellationToken);
+ }
+
public async Task> EmbedAsync(string content, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(content))
diff --git a/Journal.Core/Services/Config/JournalConfigService.cs b/Journal.Core/Services/Config/JournalConfigService.cs
index 14e245a..d20f531 100644
--- a/Journal.Core/Services/Config/JournalConfigService.cs
+++ b/Journal.Core/Services/Config/JournalConfigService.cs
@@ -21,8 +21,8 @@ public sealed class JournalConfigService : IJournalConfigService
if (nlpBackend is not ("auto" or "spacy" or "fallback"))
nlpBackend = "auto";
- var aiProvider = (Environment.GetEnvironmentVariable("JOURNAL_AI_PROVIDER") ?? "none").Trim().ToLowerInvariant();
- if (aiProvider is not ("none" or "python-sidecar"))
+ var aiProvider = (Environment.GetEnvironmentVariable("JOURNAL_AI_PROVIDER") ?? "llamasharp").Trim().ToLowerInvariant();
+ if (aiProvider is not ("none" or "python-sidecar" or "llamasharp"))
aiProvider = "none";
var pythonExecutable = Environment.GetEnvironmentVariable("JOURNAL_PYTHON_EXE");
@@ -57,7 +57,8 @@ public sealed class JournalConfigService : IJournalConfigService
AiProvider: aiProvider,
PythonExecutable: pythonExecutable,
PythonAiSidecarPath: pythonAiSidecarPath,
- AiSidecarTimeoutMs: aiSidecarTimeoutMs);
+ AiSidecarTimeoutMs: aiSidecarTimeoutMs,
+ GgufModelPath: ResolveGgufModelPath(projectRoot));
}
private static string ResolveProjectRoot()
@@ -101,5 +102,22 @@ public sealed class JournalConfigService : IJournalConfigService
return null;
return int.TryParse(value, out var parsed) ? parsed : null;
}
+
+ private static string ResolveGgufModelPath(string projectRoot)
+ {
+ var fromEnv = Environment.GetEnvironmentVariable("JOURNAL_GGUF_MODEL_PATH");
+ if (!string.IsNullOrWhiteSpace(fromEnv))
+ return Path.GetFullPath(fromEnv);
+
+ var modelsDir = Path.Combine(projectRoot, "models");
+ if (Directory.Exists(modelsDir))
+ {
+ var first = Directory.EnumerateFiles(modelsDir, "*.gguf").FirstOrDefault();
+ if (first is not null)
+ return Path.GetFullPath(first);
+ }
+
+ return Path.Combine(modelsDir, "model.gguf");
+ }
}
diff --git a/Journal.Core/Services/Conversations/ConversationService.cs b/Journal.Core/Services/Conversations/ConversationService.cs
new file mode 100644
index 0000000..c1949f1
--- /dev/null
+++ b/Journal.Core/Services/Conversations/ConversationService.cs
@@ -0,0 +1,68 @@
+using System.ComponentModel.DataAnnotations;
+using Journal.Core.Dtos;
+using Journal.Core.Models;
+using Journal.Core.Repositories;
+
+namespace Journal.Core.Services.Conversations;
+
+public class ConversationService(IConversationRepository repo) : IConversationService
+{
+ private readonly IConversationRepository _repo = repo ?? throw new ArgumentNullException(nameof(repo));
+
+ private static ConversationDto MapSummary(Conversation c) => new(c.Id, c.Title, c.CreatedAt, c.UpdatedAt);
+
+ private static ConversationMessageDto MapMessage(ConversationMessage m) => new(m.Id, m.Role, m.Text, m.CreatedAt);
+
+ public List GetAll()
+ {
+ var items = _repo.GetAll();
+ return [.. items.Select(MapSummary)];
+ }
+
+ public ConversationDetailDto? GetById(Guid id)
+ {
+ var c = _repo.GetById(id);
+ if (c is null) return null;
+
+ var messages = _repo.GetMessages(id);
+ return new ConversationDetailDto(
+ c.Id, c.Title,
+ [.. messages.Select(MapMessage)],
+ c.CreatedAt, c.UpdatedAt);
+ }
+
+ public ConversationDto Create(CreateConversationDto dto)
+ {
+ ArgumentNullException.ThrowIfNull(dto);
+ var ctx = new ValidationContext(dto);
+ Validator.ValidateObject(dto, ctx, validateAllProperties: true);
+
+ var conversation = new Conversation(dto.Title);
+ _repo.Add(conversation);
+ return MapSummary(conversation);
+ }
+
+ public bool Update(Guid id, UpdateConversationDto dto)
+ {
+ ArgumentNullException.ThrowIfNull(dto);
+ if (dto.Title is not null && string.IsNullOrWhiteSpace(dto.Title))
+ throw new ValidationException("Title cannot be empty");
+
+ return _repo.Update(id, dto.Title?.Trim());
+ }
+
+ public bool Remove(Guid id) => _repo.Remove(id);
+
+ public ConversationMessageDto AddMessage(Guid conversationId, string role, string text)
+ {
+ var message = new ConversationMessage(conversationId, role, text);
+ _repo.AddMessage(message);
+ return MapMessage(message);
+ }
+
+ public List GetMessages(Guid conversationId)
+ {
+ var messages = _repo.GetMessages(conversationId);
+ return [.. messages.Select(MapMessage)];
+ }
+}
diff --git a/Journal.Core/Services/Conversations/IConversationService.cs b/Journal.Core/Services/Conversations/IConversationService.cs
new file mode 100644
index 0000000..92c79b4
--- /dev/null
+++ b/Journal.Core/Services/Conversations/IConversationService.cs
@@ -0,0 +1,14 @@
+using Journal.Core.Dtos;
+
+namespace Journal.Core.Services.Conversations;
+
+public interface IConversationService
+{
+ List GetAll();
+ ConversationDetailDto? GetById(Guid id);
+ ConversationDto Create(CreateConversationDto dto);
+ bool Update(Guid id, UpdateConversationDto dto);
+ bool Remove(Guid id);
+ ConversationMessageDto AddMessage(Guid conversationId, string role, string text);
+ List GetMessages(Guid conversationId);
+}
diff --git a/Journal.Core/Services/Database/JournalDatabaseService.cs b/Journal.Core/Services/Database/JournalDatabaseService.cs
index d762eab..b5eb46c 100644
--- a/Journal.Core/Services/Database/JournalDatabaseService.cs
+++ b/Journal.Core/Services/Database/JournalDatabaseService.cs
@@ -15,7 +15,7 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
private static readonly Lock SqliteInitLock = new();
private static bool _sqliteInitialized;
private static readonly IReadOnlyList RequiredSchemaTables =
- ["entries", "sections", "fragments", "tags", "fragment_tags", "lists", "todo_lists", "todo_items", "entry_documents"];
+ ["entries", "sections", "fragments", "tags", "fragment_tags", "lists", "todo_lists", "todo_items", "entry_documents", "conversations", "conversation_messages"];
private readonly IJournalConfigService _config = config;
@@ -129,6 +129,26 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
is_template INTEGER NOT NULL DEFAULT 0,
updated_at TEXT
);
+ """,
+ ["conversations"] = """
+ CREATE TABLE IF NOT EXISTS conversations (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ guid TEXT UNIQUE,
+ title TEXT NOT NULL,
+ created_at TEXT,
+ updated_at TEXT
+ );
+ """,
+ ["conversation_messages"] = """
+ CREATE TABLE IF NOT EXISTS conversation_messages (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ guid TEXT UNIQUE,
+ conversation_id INTEGER NOT NULL,
+ role TEXT NOT NULL,
+ text TEXT NOT NULL,
+ created_at TEXT,
+ FOREIGN KEY (conversation_id) REFERENCES conversations (id)
+ );
"""
};
}
diff --git a/Journal.Sidecar/App.cs b/Journal.Sidecar/App.cs
index e72ae6a..95f3f3e 100644
--- a/Journal.Sidecar/App.cs
+++ b/Journal.Sidecar/App.cs
@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
+using Journal.AI;
using Journal.Core;
using Journal.Core.Services.Speech;
using Journal.Core.Services.Sidecar;
@@ -9,6 +10,7 @@ Console.InputEncoding = System.Text.Encoding.UTF8;
var services = new ServiceCollection();
services.AddFragmentServices();
+services.AddLlamaSharpServices();
services.AddSingleton();
services.AddSingleton();
var provider = services.BuildServiceProvider();
diff --git a/Journal.Sidecar/Journal.Sidecar.csproj b/Journal.Sidecar/Journal.Sidecar.csproj
index 8b95a51..e9b9245 100644
--- a/Journal.Sidecar/Journal.Sidecar.csproj
+++ b/Journal.Sidecar/Journal.Sidecar.csproj
@@ -10,6 +10,7 @@
+
diff --git a/Journal.Sidecar/LocalWhisperS2TService.cs b/Journal.Sidecar/LocalWhisperS2TService.cs
index 26fe532..b53ecb9 100644
--- a/Journal.Sidecar/LocalWhisperS2TService.cs
+++ b/Journal.Sidecar/LocalWhisperS2TService.cs
@@ -16,8 +16,8 @@ public sealed class LocalWhisperS2TService : IS2TService, IDisposable
private const int MaxBufferedItems = 256;
private const int SilenceRmsThreshold = 150;
- private readonly object _sync = new();
- private readonly object _segmentLock = new();
+ private readonly Lock _sync = new();
+ private readonly Lock _segmentLock = new();
private readonly ConcurrentQueue _transcripts = new();
private WaveInEvent? _waveIn;
diff --git a/Journal.SmokeTests/GlobalUsings.cs b/Journal.SmokeTests/GlobalUsings.cs
index 57dccd9..b94867a 100644
--- a/Journal.SmokeTests/GlobalUsings.cs
+++ b/Journal.SmokeTests/GlobalUsings.cs
@@ -15,4 +15,5 @@ global using Journal.Core.Services.Speech;
global using Journal.Core.Services.Sidecar;
global using Journal.Core.Services.Lists;
global using Journal.Core.Services.Todos;
+global using Journal.Core.Services.Conversations;
global using Journal.Core.Services.Vault;
diff --git a/Journal.SmokeTests/Program.Shared.cs b/Journal.SmokeTests/Program.Shared.cs
index 09f10b1..1f07488 100644
--- a/Journal.SmokeTests/Program.Shared.cs
+++ b/Journal.SmokeTests/Program.Shared.cs
@@ -25,9 +25,12 @@ internal static partial class Program
config,
new DisabledAiService("none"),
new DisabledSpeechBridgeService("none"),
+ new DisabledS2TService(),
new EntryFileService(entryRepo),
new ListService(new SqliteListRepository(session)),
new TodoService(new SqliteTodoRepository(session)),
+ new DisabledCoachService(),
+ new ConversationService(new SqliteConversationRepository(session)),
new CommandLogger());
}
diff --git a/Journal.SmokeTests/Program.VaultTests.cs b/Journal.SmokeTests/Program.VaultTests.cs
index a625d6b..9455be4 100644
--- a/Journal.SmokeTests/Program.VaultTests.cs
+++ b/Journal.SmokeTests/Program.VaultTests.cs
@@ -437,9 +437,12 @@ internal static partial class Program
config,
new DisabledAiService("none"),
new DisabledSpeechBridgeService("none"),
+ new DisabledS2TService(),
entryFiles,
new ListService(new SqliteListRepository(session)),
new TodoService(new SqliteTodoRepository(session)),
+ new DisabledCoachService(),
+ new ConversationService(new SqliteConversationRepository(session)),
new CommandLogger());
try
diff --git a/Journal.WebGateway/Journal.WebGateway.csproj b/Journal.WebGateway/Journal.WebGateway.csproj
index 511c2f0..f4e284d 100644
--- a/Journal.WebGateway/Journal.WebGateway.csproj
+++ b/Journal.WebGateway/Journal.WebGateway.csproj
@@ -7,6 +7,7 @@
+
diff --git a/Journal.WebGateway/Program.cs b/Journal.WebGateway/Program.cs
index fca14ce..4500445 100644
--- a/Journal.WebGateway/Program.cs
+++ b/Journal.WebGateway/Program.cs
@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.Json.Serialization;
+using Journal.AI;
using Journal.Core;
using Microsoft.Extensions.FileProviders;
@@ -17,6 +18,7 @@ var webDistPath = ResolveWebDist(repoRoot);
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFragmentServices();
+builder.Services.AddLlamaSharpServices();
builder.Services.AddSingleton();
builder.Services.AddSingleton(new SidecarRootState(repoRoot));
builder.Services.AddSingleton(new WebUiState(webDistPath));
diff --git a/Journal.slnx b/Journal.slnx
index 090dfed..10b89d0 100644
--- a/Journal.slnx
+++ b/Journal.slnx
@@ -1,4 +1,5 @@
+
diff --git a/docs/frontend-csharp-backend-wiring.md b/docs/frontend-csharp-backend-wiring.md
deleted file mode 100644
index 2dd6cce..0000000
--- a/docs/frontend-csharp-backend-wiring.md
+++ /dev/null
@@ -1,218 +0,0 @@
-# Wiring Frontend to the C# Backend
-
-This document explains how to connect the `Journal.App` frontend to the C# backend in this repository.
-
-## Current Backend Reality
-
-In this repo today, the C# backend projects in `Journal.slnx` are:
-
-- `Journal.Core`
-- `Journal.Sidecar`
-- `Journal.SmokeTests`
-
-There is currently **no** `Journal.Api` project in the solution file, so the primary integration path is:
-
-- Frontend (Svelte/Tauri) -> Tauri bridge -> `Journal.Sidecar` (stdin/stdout JSON protocol)
-
-## Command Protocol (C#)
-
-`Journal.Core.Entry.HandleCommandAsync` accepts a JSON command envelope and returns:
-
-- success: `{ "ok": true, "data": ... }`
-- failure: `{ "ok": false, "error": "..." }`
-
-Command model (`Journal.Core/Models/Command.cs`):
-
-```json
-{
- "action": "entries.list",
- "correlationId": "optional-string",
- "id": "optional",
- "type": "optional",
- "tag": "optional",
- "payload": {}
-}
-```
-
-Useful actions for frontend wiring:
-
-- `entries.list`
-- `entries.load`
-- `entries.save`
-- `search.entries`
-- `vault.load_all`
-- `vault.save_current_month`
-- `db.status`
-- `db.hydrate_workspace`
-
-## Recommended Integration (Sidecar Bridge)
-
-Use a small frontend client that sends commands through one bridge function. The bridge can be backed by:
-
-- a Tauri command that talks to a managed sidecar process, or
-- a local HTTP adapter (if you add one).
-
-### 1. Define shared frontend command/response types
-
-Create `Journal.App/src/lib/backend/types.ts`:
-
-```ts
-export type BackendCommand = {
- action: string;
- correlationId?: string;
- id?: string;
- type?: string;
- tag?: string;
- payload?: unknown;
-};
-
-export type BackendOk = { ok: true; data: T };
-export type BackendErr = { ok: false; error: string };
-export type BackendResponse = BackendOk | BackendErr;
-```
-
-### 2. Create one backend client entrypoint
-
-Create `Journal.App/src/lib/backend/client.ts`:
-
-```ts
-import { invoke } from "@tauri-apps/api/core";
-import type { BackendCommand, BackendResponse } from "./types";
-
-export async function sendCommand(command: BackendCommand): Promise {
- const response = await invoke>("sidecar_command", { command });
-
- if (!response.ok) {
- throw new Error(response.error || "Backend command failed");
- }
-
- return response.data;
-}
-```
-
-This keeps all UI code backend-agnostic.
-
-### 3. Build domain helpers (entries example)
-
-Create `Journal.App/src/lib/backend/entries.ts`:
-
-```ts
-import { sendCommand } from "./client";
-
-export async function listEntries(dataDirectory?: string) {
- return sendCommand({
- action: "entries.list",
- payload: { dataDirectory }
- });
-}
-
-export async function loadEntry(filePath: string) {
- return sendCommand<{ filePath: string; content: string; section?: string }>({
- action: "entries.load",
- payload: { filePath }
- });
-}
-
-export async function saveEntry(args: {
- filePath?: string;
- content: string;
- title?: string;
- section?: string;
- date?: string;
-}) {
- return sendCommand<{ filePath: string }>({
- action: "entries.save",
- payload: args
- });
-}
-```
-
-### 4. Use client in UI state
-
-In page/component code:
-
-- on panel item click: call `loadEntry(filePath)`
-- on editor save button: call `saveEntry({ filePath, content })`
-- on app init: call `listEntries()` to populate list
-
-## Tauri Bridge Notes
-
-Your frontend should not spawn/process-manage the sidecar directly. Keep that in the Tauri layer.
-
-Bridge responsibilities:
-
-- start and keep one sidecar process alive
-- write command JSON lines to sidecar stdin
-- read stdout lines and map responses by `correlationId`
-- return parsed response to frontend
-- restart sidecar if it crashes
-
-If you have not implemented this yet, create one Tauri command such as:
-
-- `sidecar_command(command)`
-
-and route all frontend calls through it.
-
-## Vault/Auth Flow
-
-Recommended startup sequence:
-
-1. Prompt for vault password in UI.
-2. Call `vault.load_all` (or `db.hydrate_workspace`) once.
-3. Backend stores session password (`DatabaseSessionService`) for subsequent commands.
-4. Continue with `entries.list`, `entries.load`, etc.
-
-Do not store raw vault password in long-lived frontend state.
-
-## Error Handling Pattern
-
-Always normalize backend errors in one place:
-
-- backend client throws `Error(message)` when `ok: false`
-- UI catches and displays your custom modal
-- include `correlationId` on commands for tracing/logging
-
-## Optional HTTP Path (If You Add Journal.Api)
-
-If you later add `Journal.Api` with `POST /api/command`, keep the same command envelope and swap transport only:
-
-- replace `invoke("sidecar_command", ...)` with `fetch("/api/command", ...)`
-- keep `sendCommand` interface unchanged
-
-That lets UI code remain identical.
-
-## Minimal Next Steps
-
-1. Add `src/lib/backend/types.ts`, `client.ts`, `entries.ts`.
-2. Wire `EditorPanel` save button to `entries.save`.
-3. Wire `SidePanel` item load to `entries.load`.
-4. Add vault unlock modal + `vault.load_all` on startup.
-5. Keep all backend calls behind `sendCommand` only.
-
-## Frontend Store Architecture (Current)
-
-Current frontend uses feature stores in `Journal.App/src/lib/stores/`:
-
-- `entries.ts` -> `entriesStore`
-- `fragments.ts` -> `fragmentsStore`
-- `todos.ts` -> `todoListsStore`, `todosStore`
-- `lists.ts` -> `listsStore`
-- `settings.ts` -> `settingsTags`, `settingsFragmentTypes`
-
-Current pattern is store-first for most feature CRUD and parsing (especially fragments and todos), with UI components invoking store helpers.
-
-## State/CRUD Gaps Still Needed
-
-To fully standardize state management:
-
-1. Move settings add/edit/remove logic into `settings.ts` helper functions (currently in route component code).
-2. Add full CRUD helpers for `entries.ts` and `lists.ts` (update/remove/reorder, not only draft creation).
-3. Make todo list metadata + todo items update atomically through a single store API wrapper.
-4. Move calendar-created entries out of local component state into a dedicated calendar store.
-5. Add persistence/hydration strategy between stores and backend (`entries.load/save`, `vault.load_all`, etc.).
-
-## Recommended Rule
-
-- Keep all feature data mutations in store helper APIs.
-- Keep route/component files focused on view state and command orchestration.
-- Keep backend transport (`sendCommand`) separate from pure local store mutation helpers, then compose both in thin feature services.