Add persistent context sidebar, note title fix, and expanded tool suite

Context sidebar + note title:
- ChatView: replace ephemeral context pills with a persistent right-panel sidebar;
  auto-found notes accumulate across turns; attached note shows with pin icon;
  × button excludes a note from future auto-search; hidden on mobile
- routes/chat.py: batch-fetch note titles via get_notes_by_ids() and inject
  context_note_title into each message dict at conversation load time
- notes.py: add get_notes_by_ids() batch fetch helper
- types/chat.ts: add context_note_title field to Message interface
- stores/chat.ts: sendMessage accepts optional 5th arg contextNoteTitle,
  included in optimistic user message
- ChatMessage.vue: context badge shows note title instead of 'Note #N'

Expanded LLM tool suite (all with intent router rules + ToolCallCard display):
- delete_note / delete_task: permanent delete with user confirmation (write tool),
  type-safe (refuse to delete wrong type), clears note context cache on success
- get_note: fetch full note body by query (search_notes returns only 200-char preview)
- list_notes: browse notes by recency/keyword/tags with limit; notes only
- update_note: add tags + tag_mode (replace/add/remove) parameters
- search_notes: add optional type filter ("note" | "task")
- search_todos (CalDAV): keyword-filter todos, companion to list_todos
- caldav.py: add search_todos() built on top of list_todos()
- generation_task.py: register new tools in _WRITE_TOOLS, _TOOL_LABELS, _TOOL_ACTIONS
- llm.py: update available actions list and guidance in system prompt
- intent.py: routing rules for all new tools

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 14:40:34 -05:00
parent d6f4a6dbb6
commit 32e4ee12f2
13 changed files with 578 additions and 187 deletions
+1 -1
View File
@@ -81,7 +81,7 @@ const timingParts = computed((): string[] => {
class="context-badge" class="context-badge"
> >
<router-link :to="`/notes/${message.context_note_id}`"> <router-link :to="`/notes/${message.context_note_id}`">
Note #{{ message.context_note_id }} {{ message.context_note_title || `Note #${message.context_note_id}` }}
</router-link> </router-link>
</div> </div>
</div> </div>
+68
View File
@@ -20,6 +20,14 @@ const label = computed(() => {
return "Created note"; return "Created note";
case "note_updated": case "note_updated":
return "Updated note"; return "Updated note";
case "note_deleted":
return "Deleted note";
case "task_deleted":
return "Deleted task";
case "note_content":
return "Note";
case "notes_list":
return "Notes";
case "tasks": case "tasks":
return "Tasks"; return "Tasks";
case "todo_updated": case "todo_updated":
@@ -166,6 +174,32 @@ const searchResults = computed(() => {
return results && results.length > 0 ? results : null; return results && results.length > 0 ? results : null;
}); });
const deletedNote = computed(() => {
const data = props.toolCall.result.data;
const type = props.toolCall.result.type;
if (!data || (type !== "note_deleted" && type !== "task_deleted")) return null;
return data as { id: number; title: string };
});
const noteContent = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "note_content") return null;
return data as { id: number; title: string; body: string; tags: string[] };
});
const noteList = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "notes_list") return null;
const results = data.results as Array<{ id: number; title: string; tags: string[]; preview: string }> | undefined;
return results ?? [];
});
const noteListCount = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "notes_list") return 0;
return (data.total as number) ?? 0;
});
async function applyTag(tag: string) { async function applyTag(tag: string) {
if (!noteId.value || appliedTags.value.has(tag) || applyingTag.value) return; if (!noteId.value || appliedTags.value.has(tag) || applyingTag.value) return;
applyingTag.value = tag; applyingTag.value = tag;
@@ -189,6 +223,30 @@ async function applyTag(tag: string) {
<template v-else-if="toolCall.status === 'error'"> <template v-else-if="toolCall.status === 'error'">
<span class="tool-error">{{ toolCall.result.error }}</span> <span class="tool-error">{{ toolCall.result.error }}</span>
</template> </template>
<template v-else-if="deletedNote">
<span class="tool-deleted">{{ deletedNote.title || "Untitled" }}</span>
</template>
<template v-else-if="noteContent">
<router-link :to="`/notes/${noteContent.id}`" class="tool-link">{{ noteContent.title || "Untitled" }}</router-link>
<span v-if="noteContent.tags.length" class="tool-note-tags">
<span v-for="tag in noteContent.tags" :key="tag" class="tool-note-tag">#{{ tag }}</span>
</span>
</template>
<template v-else-if="noteList !== null">
<span class="tool-search-info">{{ noteListCount }} found</span>
<div v-if="noteList.length > 0" class="tool-search-results">
<router-link
v-for="n in noteList.slice(0, 8)"
:key="n.id"
:to="`/notes/${n.id}`"
class="tool-search-item"
>
{{ n.title || "Untitled" }}
</router-link>
<span v-if="noteList.length > 8" class="tool-event-more">+{{ noteList.length - 8 }} more</span>
</div>
<span v-else class="tool-search-info">No notes found</span>
</template>
<template v-else-if="searchResults"> <template v-else-if="searchResults">
<span class="tool-search-info">{{ (toolCall.result.data?.total as number) ?? 0 }} found</span> <span class="tool-search-info">{{ (toolCall.result.data?.total as number) ?? 0 }} found</span>
<div class="tool-search-results"> <div class="tool-search-results">
@@ -380,6 +438,16 @@ async function applyTag(tag: string) {
text-decoration: line-through; text-decoration: line-through;
opacity: 0.7; opacity: 0.7;
} }
.tool-note-tags {
display: flex;
flex-wrap: wrap;
gap: 0.2rem;
width: 100%;
}
.tool-note-tag {
font-size: 0.72rem;
color: var(--color-text-muted);
}
.tool-task-priority { .tool-task-priority {
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 600; font-weight: 600;
+2
View File
@@ -121,6 +121,7 @@ export const useChatStore = defineStore("chat", () => {
contextNoteId?: number | null, contextNoteId?: number | null,
excludeNoteIds?: number[], excludeNoteIds?: number[],
think = false, think = false,
contextNoteTitle?: string,
) { ) {
if (!currentConversation.value) return; if (!currentConversation.value) return;
@@ -150,6 +151,7 @@ export const useChatStore = defineStore("chat", () => {
role: "user", role: "user",
content, content,
context_note_id: contextNoteId ?? null, context_note_id: contextNoteId ?? null,
context_note_title: contextNoteTitle ?? null,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
}; };
currentConversation.value.messages.push(userMsg); currentConversation.value.messages.push(userMsg);
+1
View File
@@ -26,6 +26,7 @@ export interface Message {
content: string; content: string;
status?: "complete" | "generating" | "error"; status?: "complete" | "generating" | "error";
context_note_id: number | null; context_note_id: number | null;
context_note_title?: string | null;
tool_calls?: ToolCallRecord[] | null; tool_calls?: ToolCallRecord[] | null;
created_at: string; created_at: string;
timing?: GenerationTiming; timing?: GenerationTiming;
+156 -165
View File
@@ -33,6 +33,9 @@ let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
// Exclude tracking (session-scoped per conversation) // Exclude tracking (session-scoped per conversation)
const excludedNoteIds = ref<Set<number>>(new Set()); const excludedNoteIds = ref<Set<number>>(new Set());
// Persistent context notes — populated as assistant responds, cleared on conv change
const contextNotes = ref<{ id: number; title: string }[]>([]);
let prevConvId: number | null = null; let prevConvId: number | null = null;
const convId = computed(() => { const convId = computed(() => {
@@ -89,6 +92,7 @@ watch(convId, async (newId) => {
prevConvId = newId ?? null; prevConvId = newId ?? null;
excludedNoteIds.value = new Set(); excludedNoteIds.value = new Set();
contextNotes.value = [];
attachedNote.value = null; attachedNote.value = null;
store.lastContextMeta = null; store.lastContextMeta = null;
if (newId) { if (newId) {
@@ -109,6 +113,21 @@ watch(
() => scrollToBottom() () => scrollToBottom()
); );
watch(
() => store.lastContextMeta?.auto_notes,
(newNotes) => {
if (!newNotes) return;
const excluded = excludedNoteIds.value;
const existing = new Set(contextNotes.value.map((n) => n.id));
for (const note of newNotes) {
if (!excluded.has(note.id) && !existing.has(note.id)) {
contextNotes.value.push(note);
existing.add(note.id);
}
}
}
);
function scrollToBottom() { function scrollToBottom() {
nextTick(() => { nextTick(() => {
if (messagesEl.value) { if (messagesEl.value) {
@@ -152,6 +171,7 @@ async function sendMessage() {
sending.value = true; sending.value = true;
const contextNoteId = attachedNote.value?.id ?? undefined; const contextNoteId = attachedNote.value?.id ?? undefined;
const contextNoteTitle = attachedNote.value?.title ?? undefined;
attachedNote.value = null; attachedNote.value = null;
messageInput.value = ""; messageInput.value = "";
resetTextareaHeight(); resetTextareaHeight();
@@ -162,6 +182,7 @@ async function sendMessage() {
contextNoteId, contextNoteId,
excludedNoteIds.value.size ? [...excludedNoteIds.value] : undefined, excludedNoteIds.value.size ? [...excludedNoteIds.value] : undefined,
true, // enable thinking in the full chat view true, // enable thinking in the full chat view
contextNoteTitle,
); );
sending.value = false; sending.value = false;
@@ -261,12 +282,9 @@ function removeAttachedNote() {
attachedNote.value = null; attachedNote.value = null;
} }
function promoteAutoNote(note: { id: number; title: string }) {
attachedNote.value = note;
}
function excludeAutoNote(noteId: number) { function excludeAutoNote(noteId: number) {
excludedNoteIds.value = new Set([...excludedNoteIds.value, noteId]); excludedNoteIds.value = new Set([...excludedNoteIds.value, noteId]);
contextNotes.value = contextNotes.value.filter((n) => n.id !== noteId);
} }
// Keyboard shortcuts // Keyboard shortcuts
@@ -355,88 +373,77 @@ onUnmounted(() => {
</button> </button>
</div> </div>
<div ref="messagesEl" class="messages-container"> <div class="chat-body">
<div class="messages-inner"> <div ref="messagesEl" class="messages-container">
<ChatMessage <div class="messages-inner">
v-for="msg in store.currentConversation.messages" <ChatMessage
:key="msg.id" v-for="msg in store.currentConversation.messages"
:message="msg" :key="msg.id"
@save-as-note="handleSaveAsNote" :message="msg"
/> @save-as-note="handleSaveAsNote"
/>
<!-- Context pills (auto-found notes) --> <!-- Streaming message (assistant typing) -->
<div <div v-if="store.streaming" class="chat-message role-assistant">
v-if="store.lastContextMeta?.auto_notes?.length && (store.streaming || store.currentConversation.messages.length)" <div class="message-bubble streaming-bubble">
class="context-pills" <div class="message-header">
> <span class="role-label">{{ settingsStore.assistantName }}</span>
<span class="context-pills-label">Context:</span> </div>
<span <div v-if="store.streamingToolCalls.length" class="streaming-tool-calls">
v-for="note in store.lastContextMeta.auto_notes" <ToolCallCard
:key="note.id" v-for="(tc, i) in store.streamingToolCalls"
class="context-pill" :key="i"
> :tool-call="tc"
<router-link :to="`/notes/${note.id}`" class="context-pill-link"> />
{{ note.title }} </div>
</router-link> <ToolConfirmCard
<button v-if="store.streamingPendingTool"
class="context-pill-btn promote" :pending-tool="store.streamingPendingTool"
@click="promoteAutoNote(note)" @accept="store.confirmTool(true)"
title="Attach this note for next message" @decline="store.confirmTool(false)"
>+</button>
<button
class="context-pill-btn exclude"
@click="excludeAutoNote(note.id)"
title="Exclude from auto-search"
>&times;</button>
</span>
</div>
<!-- Streaming message (assistant typing) -->
<div v-if="store.streaming" class="chat-message role-assistant">
<div class="message-bubble streaming-bubble">
<div class="message-header">
<span class="role-label">{{ settingsStore.assistantName }}</span>
</div>
<div v-if="store.streamingToolCalls.length" class="streaming-tool-calls">
<ToolCallCard
v-for="(tc, i) in store.streamingToolCalls"
:key="i"
:tool-call="tc"
/> />
<div v-if="store.streamingStatus" class="streaming-status-line">
<span class="streaming-status-dot"></span>
{{ store.streamingStatus }}
</div>
<div class="message-content prose" v-html="streamingRendered"></div>
<span v-if="!store.streamingStatus" class="typing-indicator"></span>
</div> </div>
<ToolConfirmCard
v-if="store.streamingPendingTool"
:pending-tool="store.streamingPendingTool"
@accept="store.confirmTool(true)"
@decline="store.confirmTool(false)"
/>
<div v-if="store.streamingStatus" class="streaming-status-line">
<span class="streaming-status-dot"></span>
{{ store.streamingStatus }}
</div>
<div class="message-content prose" v-html="streamingRendered"></div>
<span v-if="!store.streamingStatus" class="typing-indicator"></span>
</div> </div>
<p
v-if="!store.currentConversation.messages.length && !store.streaming"
class="empty-msg"
>
Send a message to start the conversation.
</p>
</div>
</div>
<!-- Persistent context sidebar -->
<aside v-if="contextNotes.length || attachedNote" class="context-sidebar">
<div class="context-sidebar-header">In Context</div>
<!-- Manually attached note pinned until sent -->
<div v-if="attachedNote" class="context-note context-note-pinned">
<span class="context-note-icon" title="Attached for this message">📌</span>
<router-link :to="`/notes/${attachedNote.id}`" class="context-note-name">
{{ attachedNote.title }}
</router-link>
<button class="context-note-remove" @click="removeAttachedNote" title="Remove">&times;</button>
</div> </div>
<p <!-- Auto-found notes persist across turns -->
v-if="!store.currentConversation.messages.length && !store.streaming" <div v-for="note in contextNotes" :key="note.id" class="context-note">
class="empty-msg" <router-link :to="`/notes/${note.id}`" class="context-note-name">
> {{ note.title }}
Send a message to start the conversation. </router-link>
</p> <button class="context-note-remove" @click="excludeAutoNote(note.id)" title="Remove from context">&times;</button>
</div> </div>
</aside>
</div> </div>
<div class="input-wrapper"> <div class="input-wrapper">
<!-- Attached note pill -->
<div v-if="attachedNote" class="attached-note">
<span class="attached-note-pill">
{{ attachedNote.title }}
<button class="attached-note-remove" @click="removeAttachedNote">&times;</button>
</span>
</div>
<div class="input-area"> <div class="input-area">
<!-- Note picker button --> <!-- Note picker button -->
<div class="note-picker-wrapper"> <div class="note-picker-wrapper">
@@ -656,6 +663,13 @@ onUnmounted(() => {
cursor: default; cursor: default;
} }
.chat-body {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
}
.messages-container { .messages-container {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@@ -666,6 +680,68 @@ onUnmounted(() => {
margin: 0 auto; margin: 0 auto;
width: 100%; width: 100%;
} }
.context-sidebar {
width: 220px;
min-width: 180px;
border-left: 1px solid var(--color-border);
background: var(--color-bg-secondary);
display: flex;
flex-direction: column;
padding: 0.75rem;
gap: 0.35rem;
overflow-y: auto;
}
.context-sidebar-header {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
margin-bottom: 0.25rem;
}
.context-note {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.4rem;
border-radius: var(--radius-sm);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
font-size: 0.82rem;
}
.context-note-pinned {
border-color: var(--color-primary);
}
.context-note-icon {
font-size: 0.75rem;
flex-shrink: 0;
}
.context-note-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text);
text-decoration: none;
}
.context-note-name:hover {
color: var(--color-primary);
}
.context-note-remove {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
font-size: 1rem;
line-height: 1;
padding: 0 0.1rem;
flex-shrink: 0;
}
.context-note-remove:hover {
color: var(--color-danger, #e74c3c);
}
.messages-inner { .messages-inner {
margin-top: auto; margin-top: auto;
} }
@@ -743,67 +819,6 @@ onUnmounted(() => {
50% { opacity: 1; } 50% { opacity: 1; }
} }
/* Context pills */
.context-pills {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
padding: 0.5rem 0;
}
.context-pills-label {
font-size: 0.75rem;
color: var(--color-text-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.context-pill {
display: inline-flex;
align-items: center;
gap: 0.15rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 0.15rem 0.35rem 0.15rem 0.5rem;
font-size: 0.8rem;
}
.context-pill-link {
color: var(--color-primary);
text-decoration: none;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.context-pill-link:hover {
text-decoration: underline;
}
.context-pill-btn {
background: none;
border: none;
cursor: pointer;
font-size: 0.85rem;
line-height: 1;
padding: 0 0.15rem;
color: var(--color-text-muted);
border-radius: 50%;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
}
.context-pill-btn:hover {
background: var(--color-border);
}
.context-pill-btn.promote:hover {
color: var(--color-primary);
}
.context-pill-btn.exclude:hover {
color: var(--color-danger, #e74c3c);
}
/* Input wrapper */ /* Input wrapper */
.input-wrapper { .input-wrapper {
max-width: 960px; max-width: 960px;
@@ -813,33 +828,6 @@ onUnmounted(() => {
box-sizing: border-box; box-sizing: border-box;
} }
/* Attached note */
.attached-note {
padding: 0.25rem 0.5rem;
}
.attached-note-pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: var(--color-primary);
color: #fff;
border-radius: 12px;
padding: 0.2rem 0.5rem;
font-size: 0.8rem;
}
.attached-note-remove {
background: none;
border: none;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 0 0.15rem;
}
.attached-note-remove:hover {
color: #fff;
}
/* Floating input bar */ /* Floating input bar */
.input-area { .input-area {
display: flex; display: flex;
@@ -1038,6 +1026,9 @@ onUnmounted(() => {
background: var(--color-overlay); background: var(--color-overlay);
z-index: 99; z-index: 99;
} }
.context-sidebar {
display: none;
}
.messages-container { .messages-container {
padding: 0.75rem; padding: 0.75rem;
} }
+9 -1
View File
@@ -23,6 +23,7 @@ from fabledassistant.services.generation_buffer import (
get_buffer, get_buffer,
) )
from fabledassistant.services.generation_task import run_generation from fabledassistant.services.generation_task import run_generation
from fabledassistant.services.notes import get_notes_by_ids
from fabledassistant.services.settings import get_setting from fabledassistant.services.settings import get_setting
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -62,7 +63,14 @@ async def get_conversation_route(conv_id: int):
if conv is None: if conv is None:
return jsonify({"error": "Conversation not found"}), 404 return jsonify({"error": "Conversation not found"}), 404
result = conv.to_dict() result = conv.to_dict()
result["messages"] = [m.to_dict() for m in conv.messages] note_ids = [m.context_note_id for m in conv.messages if m.context_note_id]
note_map = await get_notes_by_ids(uid, note_ids) if note_ids else {}
result["messages"] = []
for m in conv.messages:
msg_dict = m.to_dict()
if m.context_note_id and m.context_note_id in note_map:
msg_dict["context_note_title"] = note_map[m.context_note_id].title
result["messages"].append(msg_dict)
return jsonify(result) return jsonify(result)
+15
View File
@@ -530,6 +530,21 @@ async def list_todos(
return await asyncio.to_thread(_list) return await asyncio.to_thread(_list)
async def search_todos(
user_id: int,
query: str,
include_completed: bool = False,
calendar_name: str | None = None,
) -> list[dict]:
"""Search CalDAV todos by keyword in summary or description."""
todos = await list_todos(user_id, include_completed=include_completed, calendar_name=calendar_name)
q = query.lower()
return [
t for t in todos
if q in t.get("summary", "").lower() or q in (t.get("description") or "").lower()
]
async def complete_todo( async def complete_todo(
user_id: int, user_id: int,
query: str, query: str,
@@ -41,6 +41,10 @@ _TOOL_LABELS: dict[str, str] = {
"create_task": "Creating task", "create_task": "Creating task",
"create_note": "Creating note", "create_note": "Creating note",
"update_note": "Updating note", "update_note": "Updating note",
"delete_note": "Deleting note",
"delete_task": "Deleting task",
"get_note": "Reading note",
"list_notes": "Listing notes",
"list_tasks": "Searching tasks", "list_tasks": "Searching tasks",
"search_notes": "Searching notes", "search_notes": "Searching notes",
"create_event": "Creating calendar event", "create_event": "Creating calendar event",
@@ -51,6 +55,7 @@ _TOOL_LABELS: dict[str, str] = {
"list_calendars": "Listing calendars", "list_calendars": "Listing calendars",
"create_todo": "Creating todo", "create_todo": "Creating todo",
"list_todos": "Listing todos", "list_todos": "Listing todos",
"search_todos": "Searching todos",
"update_todo": "Updating todo", "update_todo": "Updating todo",
"complete_todo": "Completing todo", "complete_todo": "Completing todo",
"delete_todo": "Removing todo", "delete_todo": "Removing todo",
@@ -58,7 +63,7 @@ _TOOL_LABELS: dict[str, str] = {
# Tools that write data and require explicit user confirmation before executing. # Tools that write data and require explicit user confirmation before executing.
_WRITE_TOOLS: frozenset[str] = frozenset({ _WRITE_TOOLS: frozenset[str] = frozenset({
"create_task", "create_note", "update_note", "create_task", "create_note", "update_note", "delete_note", "delete_task",
"create_event", "update_event", "delete_event", "create_event", "update_event", "delete_event",
"create_todo", "update_todo", "complete_todo", "delete_todo", "create_todo", "update_todo", "complete_todo", "delete_todo",
}) })
@@ -68,8 +73,13 @@ _TOOL_ACTIONS: dict[str, str] = {
"create_task": "create a task", "create_task": "create a task",
"create_note": "create a new note", "create_note": "create a new note",
"update_note": "update an existing note", "update_note": "update an existing note",
"delete_note": "permanently delete a note",
"delete_task": "permanently delete a task",
"get_note": "read a note",
"list_notes": "list notes",
"list_tasks": "look up tasks", "list_tasks": "look up tasks",
"search_notes": "search through notes", "search_notes": "search through notes",
"search_todos": "search calendar todos",
"create_event": "schedule a calendar event", "create_event": "schedule a calendar event",
"list_events": "check the calendar", "list_events": "check the calendar",
"search_events": "search calendar events", "search_events": "search calendar events",
@@ -351,7 +361,7 @@ async def run_generation(
# Invalidate the note context cache after any successful note write # Invalidate the note context cache after any successful note write
# so the next turn can pick up newly created/modified notes. # so the next turn can pick up newly created/modified notes.
if result.get("success") and tool_name in {"create_task", "create_note", "update_note"}: if result.get("success") and tool_name in {"create_task", "create_note", "update_note", "delete_note", "delete_task"}:
clear_conv_note_cache(conv_id) clear_conv_note_cache(conv_id)
tool_record = { tool_record = {
@@ -422,7 +432,7 @@ async def run_generation(
timing["tools"].append({"name": tool_name, "ms": int((time.monotonic() - t_tool) * 1000)}) timing["tools"].append({"name": tool_name, "ms": int((time.monotonic() - t_tool) * 1000)})
logger.info("Tool %s result: success=%s", tool_name, result.get("success")) logger.info("Tool %s result: success=%s", tool_name, result.get("success"))
if result.get("success") and tool_name in {"create_task", "create_note", "update_note"}: if result.get("success") and tool_name in {"create_task", "create_note", "update_note", "delete_note", "delete_task"}:
clear_conv_note_cache(conv_id) clear_conv_note_cache(conv_id)
tool_record = { tool_record = {
+6
View File
@@ -89,10 +89,16 @@ Rules:
- "which calendars", "list calendars", "my calendars" → use list_calendars. - "which calendars", "list calendars", "my calendars" → use list_calendars.
- "create a calendar todo", "add a CalDAV todo" → use create_todo. Default to create_task for general tasks unless the user explicitly mentions CalDAV or calendar todo. - "create a calendar todo", "add a CalDAV todo" → use create_todo. Default to create_task for general tasks unless the user explicitly mentions CalDAV or calendar todo.
- "show calendar todos", "list calendar todos" → use list_todos. - "show calendar todos", "list calendar todos" → use list_todos.
- "find calendar todo", "search calendar todos", "find todo named X" → use search_todos with query=<keyword>.
- "completed/finished calendar todo" → use complete_todo. - "completed/finished calendar todo" → use complete_todo.
- "delete/remove calendar todo" → use delete_todo. - "delete/remove calendar todo" → use delete_todo.
- "remind me X minutes/hours before" an event → convert to reminder_minutes parameter on create_event. - "remind me X minutes/hours before" an event → convert to reminder_minutes parameter on create_event.
- When the user asks about events in a time period (e.g. "events in September", "what's on next week", "my schedule for March"), use list_events with date_from/date_to covering that period. Do NOT use search_events for time-based queries — search_events is only for keyword matching against event titles. - When the user asks about events in a time period (e.g. "events in September", "what's on next week", "my schedule for March"), use list_events with date_from/date_to covering that period. Do NOT use search_events for time-based queries — search_events is only for keyword matching against event titles.
- "delete", "remove", "trash", "get rid of" a note (not a task) → use delete_note with query=<note name/keyword>. NEVER use this for tasks.
- "delete", "remove", "trash", "get rid of" a task → use delete_task with query=<task name/keyword>. NEVER use this for notes.
- "read", "open", "show me", "what does X say", "display", "pull up" a specific note → use get_note with query=<note name>.
- "list my notes", "show notes", "recent notes", "browse notes", "notes tagged X" → use list_notes (with optional q or tags).
- "tag X with Y", "add tag Y to X", "untag Y from X", "remove tag Y from X" → use update_note with tags=[Y] and tag_mode="add" or "remove".
- Do NOT wrap the JSON in markdown code fences.""" - Do NOT wrap the JSON in markdown code fences."""
+8 -4
View File
@@ -324,23 +324,27 @@ async def build_context(
tool_lines = [ tool_lines = [
"You have access to tool functions. You MUST use them when the user asks you to create, add, find, schedule, or search for anything.", "You have access to tool functions. You MUST use them when the user asks you to create, add, find, schedule, or search for anything.",
"CRITICAL: Call the tool functions directly. NEVER write out function calls as text or code. NEVER describe what you would do — just do it.", "CRITICAL: Call the tool functions directly. NEVER write out function calls as text or code. NEVER describe what you would do — just do it.",
"Available actions: create_task, create_note, update_note, list_tasks, search_notes.", "Available actions: create_task, create_note, update_note, delete_note, delete_task, get_note, list_notes, list_tasks, search_notes.",
] ]
if has_caldav: if has_caldav:
tool_lines[-1] = ( tool_lines[-1] = (
"Available actions: create_task, create_note, update_note, list_tasks, search_notes, " "Available actions: create_task, create_note, update_note, delete_note, delete_task, get_note, list_notes, list_tasks, search_notes, "
"create_event, list_events, search_events, update_event, delete_event, " "create_event, list_events, search_events, update_event, delete_event, "
"list_calendars, create_todo, list_todos, update_todo, complete_todo, delete_todo." "list_calendars, create_todo, list_todos, search_todos, update_todo, complete_todo, delete_todo."
) )
tool_lines.append("For calendar events, use ISO 8601 datetime format (e.g. 2026-09-30T00:00:00).") tool_lines.append("For calendar events, use ISO 8601 datetime format (e.g. 2026-09-30T00:00:00).")
tool_lines.append("CalDAV todos are separate from app tasks. Use create_task for app tasks, create_todo for CalDAV todos.") tool_lines.append("CalDAV todos are separate from app tasks. Use create_task for app tasks, create_todo for CalDAV todos.")
tool_lines.append("When the user says 'remind me' with a time before an event, use the reminder_minutes parameter.") tool_lines.append("When the user says 'remind me' with a time before an event, use the reminder_minutes parameter.")
tool_lines.append("Use search_todos to find a specific CalDAV todo by keyword when list_todos would return too many results.")
tool_lines.append("For relative dates like 'Friday' or 'next week', resolve them to YYYY-MM-DD format.") tool_lines.append("For relative dates like 'Friday' or 'next week', resolve them to YYYY-MM-DD format.")
tool_lines.append( tool_lines.append(
"Use update_note to edit/expand an existing note OR to update a task's status/priority/due_date. " "Use update_note to edit/expand an existing note OR to update a task's status/priority/due_date. "
"Use create_note ONLY for genuinely new notes with a different title. " "Use create_note ONLY for genuinely new notes with a different title. "
"Use list_tasks to find tasks by status, priority, or due date (e.g. overdue, high priority, in progress). " "Use list_tasks to find tasks by status, priority, or due date (e.g. overdue, high priority, in progress). "
"If a note was created earlier in the conversation and the user provides more content for it, use update_note." "If a note was created earlier in the conversation and the user provides more content for it, use update_note. "
"Use get_note to read the full content of a specific note. "
"Use list_notes to browse notes by recency or tag. "
"Use delete_note / delete_task only when explicitly asked to delete — these require confirmation."
) )
tool_guidance = "\n".join(tool_lines) tool_guidance = "\n".join(tool_lines)
+11
View File
@@ -250,6 +250,17 @@ async def search_notes_for_context(
return list(result.scalars().all()) return list(result.scalars().all())
async def get_notes_by_ids(user_id: int, note_ids: list[int]) -> dict[int, Note]:
"""Batch fetch notes by ID list. Returns {note_id: Note}."""
if not note_ids:
return {}
async with async_session() as session:
result = await session.execute(
select(Note).where(Note.user_id == user_id, Note.id.in_(note_ids))
)
return {n.id: n for n in result.scalars().all()}
async def get_backlinks(user_id: int, note_id: int) -> list[dict]: async def get_backlinks(user_id: int, note_id: int) -> list[dict]:
note = await get_note(user_id, note_id) note = await get_note(user_id, note_id)
if note is None: if note is None:
+264 -2
View File
@@ -15,10 +15,11 @@ from fabledassistant.services.caldav import (
list_events, list_events,
list_todos, list_todos,
search_events, search_events,
search_todos,
update_event, update_event,
update_todo, update_todo,
) )
from fabledassistant.services.notes import create_note, get_note_by_title, list_notes, update_note from fabledassistant.services.notes import create_note, delete_note, get_note_by_title, list_notes, update_note
from fabledassistant.services.tag_suggestions import suggest_tags from fabledassistant.services.tag_suggestions import suggest_tags
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -132,6 +133,16 @@ _CORE_TOOLS = [
"type": "string", "type": "string",
"description": "New due date in YYYY-MM-DD format", "description": "New due date in YYYY-MM-DD format",
}, },
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Tags to apply (see tag_mode for how they're applied)",
},
"tag_mode": {
"type": "string",
"enum": ["replace", "add", "remove"],
"description": "'replace' sets tags to exactly this list, 'add' appends without duplicates, 'remove' removes listed tags (default: replace)",
},
}, },
"required": ["query"], "required": ["query"],
}, },
@@ -149,6 +160,104 @@ _CORE_TOOLS = [
"type": "string", "type": "string",
"description": "Search query to find notes/tasks", "description": "Search query to find notes/tasks",
}, },
"type": {
"type": "string",
"enum": ["note", "task"],
"description": "Restrict results to only notes or only tasks. Omit to search both.",
},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "get_note",
"description": (
"Retrieve the full content of a specific note. Use this when the user asks to read, "
"view, or check what a particular note says. Returns the complete note body."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Title or keyword to identify the note",
},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "list_notes",
"description": (
"Browse the user's notes (not tasks). Use this when the user asks to see, list, or browse "
"their notes — by recency, keyword, or tag. For tasks, use list_tasks instead."
),
"parameters": {
"type": "object",
"properties": {
"q": {
"type": "string",
"description": "Optional keyword filter",
},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Filter notes that have ALL of these tags",
},
"sort": {
"type": "string",
"enum": ["updated_at", "created_at", "title"],
"description": "Sort field (default: updated_at)",
},
"limit": {
"type": "integer",
"description": "Maximum number of notes to return (default 10)",
},
},
},
},
},
{
"type": "function",
"function": {
"name": "delete_note",
"description": (
"Delete a note permanently. Use ONLY when the user explicitly asks to delete or remove a note. "
"This action requires user confirmation and cannot be undone."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Title or keyword to find the note to delete",
},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "delete_task",
"description": (
"Delete a task permanently. Use ONLY when the user explicitly asks to delete or remove a task. "
"This action requires user confirmation and cannot be undone."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Title or keyword to find the task to delete",
},
}, },
"required": ["query"], "required": ["query"],
}, },
@@ -518,6 +627,34 @@ _CALDAV_TOOLS = [
}, },
}, },
}, },
{
"type": "function",
"function": {
"name": "search_todos",
"description": (
"Search CalDAV todos by keyword. Use this when the user asks to find a specific calendar todo "
"by name or content, rather than listing all todos."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Keyword to search for in todo summary or description",
},
"include_completed": {
"type": "boolean",
"description": "Include completed todos in search (default false)",
},
"calendar_name": {
"type": "string",
"description": "Optional calendar name to restrict search",
},
},
"required": ["query"],
},
},
},
] ]
@@ -622,6 +759,21 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
update_fields["priority"] = arguments["priority"] update_fields["priority"] = arguments["priority"]
if "due_date" in arguments: if "due_date" in arguments:
update_fields["due_date"] = _parse_due_date(arguments["due_date"]) update_fields["due_date"] = _parse_due_date(arguments["due_date"])
if "tags" in arguments:
new_tags = arguments["tags"]
if isinstance(new_tags, list):
tag_mode = arguments.get("tag_mode", "replace")
if tag_mode == "add":
existing = list(note.tags or [])
for t in new_tags:
if t not in existing:
existing.append(t)
update_fields["tags"] = existing
elif tag_mode == "remove":
existing = list(note.tags or [])
update_fields["tags"] = [t for t in existing if t not in new_tags]
else:
update_fields["tags"] = new_tags
updated = await update_note(user_id, note.id, **update_fields) updated = await update_note(user_id, note.id, **update_fields)
if updated is None: if updated is None:
@@ -673,7 +825,13 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
elif tool_name == "search_notes": elif tool_name == "search_notes":
query = arguments.get("query", "") query = arguments.get("query", "")
notes, total = await list_notes(user_id=user_id, q=query, limit=5) type_filter = arguments.get("type")
is_task: bool | None = None
if type_filter == "note":
is_task = False
elif type_filter == "task":
is_task = True
notes, total = await list_notes(user_id=user_id, q=query, is_task=is_task, limit=5)
results = [] results = []
for n in notes: for n in notes:
results.append({ results.append({
@@ -859,6 +1017,110 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
"data": result, "data": result,
} }
elif tool_name == "search_todos":
results = await search_todos(
user_id=user_id,
query=arguments.get("query", ""),
include_completed=bool(arguments.get("include_completed", False)),
calendar_name=arguments.get("calendar_name"),
)
return {
"success": True,
"type": "todos",
"data": {
"count": len(results),
"todos": results,
},
}
elif tool_name == "get_note":
query = arguments.get("query", "")
note = await get_note_by_title(user_id, query)
if note is None:
notes, _ = await list_notes(user_id=user_id, q=query, limit=3)
if not notes:
return {"success": False, "error": f"No note found matching '{query}'."}
note = notes[0]
return {
"success": True,
"type": "note_content",
"data": {
"id": note.id,
"title": note.title,
"body": note.body or "",
"tags": note.tags or [],
},
}
elif tool_name == "list_notes":
tags_raw = arguments.get("tags")
tags = tags_raw if isinstance(tags_raw, list) else None
notes, total = await list_notes(
user_id=user_id,
q=arguments.get("q") or arguments.get("query"),
tags=tags,
is_task=False,
sort=arguments.get("sort", "updated_at"),
order="desc",
limit=int(arguments.get("limit", 10)),
)
results = [
{
"id": n.id,
"title": n.title,
"tags": n.tags or [],
"preview": (n.body[:200] + "...") if n.body and len(n.body) > 200 else (n.body or ""),
}
for n in notes
]
return {
"success": True,
"type": "notes_list",
"data": {
"total": total,
"count": len(results),
"results": results,
},
}
elif tool_name == "delete_note":
query = arguments.get("query", "")
note = await get_note_by_title(user_id, query)
if note is None:
notes, _ = await list_notes(user_id=user_id, q=query, is_task=False, limit=3)
if not notes:
return {"success": False, "error": f"No note found matching '{query}'."}
note = notes[0]
if note.status is not None:
return {"success": False, "error": f"'{note.title}' is a task. Use delete_task instead."}
deleted = await delete_note(user_id, note.id)
if not deleted:
return {"success": False, "error": "Failed to delete note."}
return {
"success": True,
"type": "note_deleted",
"data": {"id": note.id, "title": note.title},
}
elif tool_name == "delete_task":
query = arguments.get("query", "")
note = await get_note_by_title(user_id, query)
if note is None:
notes, _ = await list_notes(user_id=user_id, q=query, is_task=True, limit=3)
if not notes:
return {"success": False, "error": f"No task found matching '{query}'."}
note = notes[0]
if note.status is None:
return {"success": False, "error": f"'{note.title}' is a note. Use delete_note instead."}
deleted = await delete_note(user_id, note.id)
if not deleted:
return {"success": False, "error": "Failed to delete task."}
return {
"success": True,
"type": "task_deleted",
"data": {"id": note.id, "title": note.title},
}
else: else:
return {"success": False, "error": f"Unknown tool: {tool_name}"} return {"success": False, "error": f"Unknown tool: {tool_name}"}
+24 -11
View File
@@ -12,7 +12,7 @@
> Include file-level details in the commit body when the change is non-trivial. > Include file-level details in the commit body when the change is non-trivial.
## Last Updated ## Last Updated
2026-02-18 — Phase 11: streaming status transparency UX + model load state indicator 2026-02-19 — Phase 12: persistent context sidebar, note title in chat, expanded tool suite
## Project Overview ## Project Overview
Fabled Assistant is a self-hosted note-taking and task-tracking application with Fabled Assistant is a self-hosted note-taking and task-tracking application with
@@ -207,6 +207,7 @@ for AI-assisted features.
- `context_note_id` tracks which note was attached as context when message was sent - `context_note_id` tracks which note was attached as context when message was sent
- `tool_calls` stores an array of tool call records `[{function, arguments, result, status}]` for assistant messages that used tools - `tool_calls` stores an array of tool call records `[{function, arguments, result, status}]` for assistant messages that used tools
- `to_dict()` returns: `id`, `conversation_id`, `role`, `content`, `status`, `context_note_id`, `tool_calls`, `created_at` - `to_dict()` returns: `id`, `conversation_id`, `role`, `content`, `status`, `context_note_id`, `tool_calls`, `created_at`
- `context_note_title` is NOT a DB column — it is synthesised at query time in `get_conversation_route` via a batch `get_notes_by_ids()` lookup and injected into the message dict so the frontend can display the note title without a separate fetch
## Project Structure (Current) ## Project Structure (Current)
``` ```
@@ -265,9 +266,9 @@ fabledassistant/
│ │ ├── generation_buffer.py # In-memory SSE event buffer with cancel_event, reconnect support, auto-cleanup; supports chat (int keys) and assist (string keys) │ │ ├── generation_buffer.py # In-memory SSE event buffer with cancel_event, reconnect support, auto-cleanup; supports chat (int keys) and assist (string keys)
│ │ ├── generation_task.py # Background asyncio tasks: run_generation (chat, DB flush, titles, intent routing + tool loop) + run_assist_generation (lightweight, no DB) │ │ ├── generation_task.py # Background asyncio tasks: run_generation (chat, DB flush, titles, intent routing + tool loop) + run_assist_generation (lightweight, no DB)
│ │ ├── intent.py # Intent routing: classify_intent() makes fast non-streaming LLM call to detect tool intent before streaming │ │ ├── intent.py # Intent routing: classify_intent() makes fast non-streaming LLM call to detect tool intent before streaming
│ │ ├── tools.py # LLM tool definitions (create_task, create_note, update_note, list_tasks, search_notes, full CalDAV suite incl. update_todo) + execute_tool dispatcher │ │ ├── tools.py # LLM tool definitions (create/delete note+task, update_note w/tag management, get_note, list_notes, search_notes w/type filter, list_tasks, full CalDAV suite incl. search_todos) + execute_tool dispatcher
│ │ ├── tag_suggestions.py # LLM-powered tag suggestions: suggest_tags() builds prompt with existing tags, calls generate_completion, parses JSON response │ │ ├── tag_suggestions.py # LLM-powered tag suggestions: suggest_tags() builds prompt with existing tags, calls generate_completion, parses JSON response
│ │ ├── caldav.py # CalDAV integration: full event lifecycle (create/list/search/update/delete), todos (create/list/complete/delete), list_calendars, timezone (ZoneInfo), reminders (VALARM), attendees, multi-calendar search │ │ ├── caldav.py # CalDAV integration: full event lifecycle (create/list/search/update/delete), todos (create/list/search/update/complete/delete), list_calendars, timezone (ZoneInfo), reminders (VALARM), attendees, multi-calendar search
│ │ ├── settings.py # Settings CRUD with user_id isolation: get_setting, set_setting, set_settings_batch, get_all_settings │ │ ├── settings.py # Settings CRUD with user_id isolation: get_setting, set_setting, set_settings_batch, get_all_settings
│ │ ├── logging.py # App logging: log_audit, log_usage, log_error, get_logs, get_log_stats, delete_old_logs, start_log_retention_loop │ │ ├── logging.py # App logging: log_audit, log_usage, log_error, get_logs, get_log_stats, delete_old_logs, start_log_retention_loop
│ │ ├── email.py # SMTP email service: get_smtp_config, is_smtp_configured, send_email, send_test_email │ │ ├── email.py # SMTP email service: get_smtp_config, is_smtp_configured, send_email, send_test_email
@@ -320,7 +321,7 @@ fabledassistant/
│ │ ├── RegisterView.vue # Register form with password confirmation; shows "closed" message when registration disabled │ │ ├── RegisterView.vue # Register form with password confirmation; shows "closed" message when registration disabled
│ │ ├── RegisterInviteView.vue # Invitation-based registration: validates token, creates account with pre-set email │ │ ├── RegisterInviteView.vue # Invitation-based registration: validates token, creates account with pre-set email
│ │ ├── UserManagementView.vue # Admin user management: registration toggle, invitations (send/revoke), user list with delete │ │ ├── UserManagementView.vue # Admin user management: registration toggle, invitations (send/revoke), user list with delete
│ │ ├── ChatView.vue # Dedicated /chat page: responsive sidebar (overlay on mobile), bubble messages, note picker, context pills, model selector in header │ │ ├── ChatView.vue # Dedicated /chat page: responsive sidebar (overlay on mobile), bubble messages, note picker, persistent context sidebar (right panel, hidden mobile), model selector in header
│ │ ├── HomeView.vue # Actionable dashboard: overdue, due today, due this week, high priority, in progress tasks; recent chats with inline chat input; recently edited notes; model warming on mount │ │ ├── HomeView.vue # Actionable dashboard: overdue, due today, due this week, high priority, in progress tasks; recent chats with inline chat input; recently edited notes; model warming on mount
│ │ ├── SettingsView.vue # Settings page: assistant name, model catalog, data export/restore (admin) │ │ ├── SettingsView.vue # Settings page: assistant name, model catalog, data export/restore (admin)
│ │ ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination │ │ ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination
@@ -335,7 +336,7 @@ fabledassistant/
│ │ ├── ChatPanel.vue # Slide-out chat panel (right side overlay, receives contextNoteId prop), bubble-style messages, floating dark input, note picker, context pills with promote/exclude │ │ ├── ChatPanel.vue # Slide-out chat panel (right side overlay, receives contextNoteId prop), bubble-style messages, floating dark input, note picker, context pills with promote/exclude
│ │ ├── ModelSelector.vue # Model dropdown (v-model pattern): fetches installed + running models, hot/cold indicators │ │ ├── ModelSelector.vue # Model dropdown (v-model pattern): fetches installed + running models, hot/cold indicators
│ │ ├── DashboardChatInput.vue # Inline chat bar: ModelSelector + note picker + textarea + send button; emits submit event │ │ ├── DashboardChatInput.vue # Inline chat bar: ModelSelector + note picker + textarea + send button; emits submit event
│ │ ├── ToolCallCard.vue # Compact card for tool call results (created task/note link, search results, errors) + suggested tag pills with apply-on-click │ │ ├── ToolCallCard.vue # Compact card for tool call results (created/deleted task/note, note content, notes list, search results, CalDAV events/todos, errors) + suggested tag pills with apply-on-click
│ │ ├── ChatMessage.vue # Message bubble: markdown rendering, tool call cards, configurable assistant name label, "Save as Note" action on assistant messages │ │ ├── ChatMessage.vue # Message bubble: markdown rendering, tool call cards, configurable assistant name label, "Save as Note" action on assistant messages
│ │ ├── NoteCard.vue # Card with rendered markdown preview (v-html), TagPill, tag-click emit, hover edit button │ │ ├── NoteCard.vue # Card with rendered markdown preview (v-html), TagPill, tag-click emit, hover edit button
│ │ ├── TaskCard.vue # Card with rendered preview (body not description), StatusBadge (clickable), PriorityBadge, due date, tags, hover edit button │ │ ├── TaskCard.vue # Card with rendered preview (body not description), StatusBadge (clickable), PriorityBadge, due date, tags, hover edit button
@@ -542,7 +543,12 @@ When adding a new migration, follow these conventions:
- Background generation with `GenerationBuffer` (in-memory SSE fan-out, `Last-Event-ID` reconnect, 60s cleanup) - Background generation with `GenerationBuffer` (in-memory SSE fan-out, `Last-Event-ID` reconnect, 60s cleanup)
- Stop generation with partial content preservation - Stop generation with partial content preservation
- Note-aware context building: current note + keyword search for related notes + URL fetching - Note-aware context building: current note + keyword search for related notes + URL fetching
- Context pills with promote/exclude controls; note picker (paperclip) in chat input - **Persistent context sidebar:** Right panel in ChatView accumulates auto-found notes across turns.
Manually attached note appears with 📌 pin, clears after send. × excludes from future auto-search.
Hidden on mobile (≤768px). Replaces old ephemeral context pills in the message stream.
- Note picker (paperclip) in chat input; attached note title passed to store for optimistic render.
`context_note_title` synthesised server-side at conversation load via batch `get_notes_by_ids()`;
message badge shows note title instead of "Note #N".
- LLM-generated conversation titles (re-generated every 10th message) - LLM-generated conversation titles (re-generated every 10th message)
- Save assistant messages as notes (LLM-titled, chat-tagged); summarize conversations - Save assistant messages as notes (LLM-titled, chat-tagged); summarize conversations
- Dedicated `/chat` page with responsive sidebar + slide-out chat panel from header - Dedicated `/chat` page with responsive sidebar + slide-out chat panel from header
@@ -573,12 +579,17 @@ When adding a new migration, follow these conventions:
Full tool suite: Full tool suite:
- `create_task` / `create_note` — create new items - `create_task` / `create_note` — create new items
- `update_note` — edit note content (replace/append) AND update task fields: `status`, `priority`, - `update_note` — edit note content (replace/append) AND update task fields: `status`, `priority`,
`due_date`; finds by exact title first, falls back to fuzzy search; prevents duplicate notes `due_date`, `tags` (with `tag_mode`: replace/add/remove); finds by exact title first, falls back to fuzzy search
- `delete_note` / `delete_task` — permanently delete (require user confirmation via confirm UI;
validates type so delete_note won't delete a task and vice versa; clears note context cache)
- `get_note` — retrieve full note body by title/keyword (search_notes only returns 200-char preview)
- `list_notes` — browse notes by recency/keyword/tags with configurable limit; notes only (use list_tasks for tasks)
- `list_tasks` — filter tasks by `status`, `priority`, `due_before`, `due_after`, `limit`; - `list_tasks` — filter tasks by `status`, `priority`, `due_before`, `due_after`, `limit`;
backed by `list_notes(is_task=True)`; enables "overdue tasks", "high priority", "in progress" queries backed by `list_notes(is_task=True)`; enables "overdue tasks", "high priority", "in progress" queries
- `search_notes` — keyword search across notes and tasks - `search_notes` — keyword search across notes and tasks; optional `type: "note"|"task"` filter
- Full CalDAV suite: `create_event`, `list_events`, `search_events`, `update_event`, `delete_event`, - Full CalDAV suite: `create_event`, `list_events`, `search_events`, `update_event`, `delete_event`,
`list_calendars`, `create_todo`, `list_todos`, `update_todo`, `complete_todo`, `delete_todo` `list_calendars`, `create_todo`, `list_todos`, `search_todos`, `update_todo`, `complete_todo`, `delete_todo`
(`search_todos` keyword-filters the todo list — companion to `list_todos`)
- **Streaming status transparency:** The backend emits `status` SSE events at each pipeline stage - **Streaming status transparency:** The backend emits `status` SSE events at each pipeline stage
so the user always sees what's happening instead of a blank progress dot. Stages: so the user always sees what's happening instead of a blank progress dot. Stages:
(1) `"Analyzing your request..."` before intent classification; (2) human-readable tool label (1) `"Analyzing your request..."` before intent classification; (2) human-readable tool label
@@ -597,11 +608,13 @@ When adding a new migration, follow these conventions:
Dedicated intent model configurable via `OLLAMA_INTENT_MODEL` env var or per-user `intent_model` Dedicated intent model configurable via `OLLAMA_INTENT_MODEL` env var or per-user `intent_model`
setting — allows a smaller/faster model for routing while the main model handles responses. setting — allows a smaller/faster model for routing while the main model handles responses.
Intent router rules cover: update/delete events, CalDAV todos, time-period → list_events (not Intent router rules cover: update/delete events, CalDAV todos, time-period → list_events (not
search_events), update_note vs create_note disambiguation, reminder_minutes conversion. search_events), update_note vs create_note disambiguation, reminder_minutes conversion,
delete_note vs delete_task disambiguation, get_note for "read/show me this note", list_notes for
"browse/list notes", tag management via update_note (tag_mode add/remove), search_todos.
- **CalDAV calendar integration:** Per-user CalDAV settings (URL, username, password, calendar name, timezone). - **CalDAV calendar integration:** Per-user CalDAV settings (URL, username, password, calendar name, timezone).
LLM tools: `create_event` (all_day, recurrence, timezone, reminder_minutes, attendees, calendar_name), LLM tools: `create_event` (all_day, recurrence, timezone, reminder_minutes, attendees, calendar_name),
`list_events`, `search_events`, `update_event`, `delete_event`, `list_calendars`, `list_events`, `search_events`, `update_event`, `delete_event`, `list_calendars`,
`create_todo`, `list_todos`, `complete_todo`, `delete_todo`. `create_todo`, `list_todos`, `search_todos`, `update_todo`, `complete_todo`, `delete_todo`.
All-day events use iCalendar DATE values; recurrence uses RRULE (e.g. `FREQ=YEARLY`). All-day events use iCalendar DATE values; recurrence uses RRULE (e.g. `FREQ=YEARLY`).
VALARM components added for reminders. Attendees via mailto: vCalAddress. VALARM components added for reminders. Attendees via mailto: vCalAddress.
Multi-calendar search: when no specific calendar configured, all calendars are scanned. Multi-calendar search: when no specific calendar configured, all calendars are scanned.