diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ce1d5f..16aa477 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.29.0] - 2026-11-01 + +### Added +- KI-Agenten (MCP) können jetzt Aufgaben löschen (`delete_task`) sowie neue Aufgabenlisten (`create_task_list`) und Listen (`create_list`) anlegen. `list_task_lists` nennt zusätzlich die Spalten des Boards, damit ein Agent eine Aufgabe in die richtige Spalte schieben kann. +- Zeichen „KI“ an Aufgaben und Listeneinträgen, die ein KI-Agent per MCP angelegt hat, und „KI geändert“ an solchen, die er zuletzt geändert hat. So siehst du auf einen Blick, was von dir stammt. Sobald du den Eintrag selbst änderst oder abhakst, verschwindet das Zeichen. + +### Changed +- Einstellungen: Export und Import haben eine eigene Kategorie „Export & Import“ (Server-Backups, Backup, Markdown-Archiv, Daten importieren). Die API-Schlüssel stehen weiter unter „Webhooks & KI-Agenten“. + ## [0.28.0] - 2026-10-31 ### Added @@ -554,7 +563,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V - Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken. - Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung. -[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.28.0...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.29.0...dev +[0.29.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.28.0...v0.29.0 [0.28.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.27.1...v0.28.0 [0.27.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.27.0...v0.27.1 [0.27.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.26.1...v0.27.0 diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 83769c0..9785937 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -84,13 +84,16 @@ MindSift ist ein [MCP](https://modelcontextprotocol.io)-Server (Streamable HTTP, | Werkzeug | Scope | Zweck | |---|---|---| -| `list_task_lists` | `tasks:read` | Aufgabenlisten mit `permission` und offenen Aufgaben | +| `list_task_lists` | `tasks:read` | Aufgabenlisten mit `permission`, offenen Aufgaben und Board-Spalten (`columns`: gültige Werte für `kanbanStatus`) | +| `create_task_list` | `tasks:write` | Eigene Aufgabenliste mit den Standardspalten anlegen (`title`, `icon`) | | `list_tasks` | `tasks:read` | Aufgaben (`taskListId`, `status` open/done/all, `dueBefore` JJJJ-MM-TT, `limit`); fällige zuerst | | `get_task` | `tasks:read` | Eine Aufgabe | | `create_task` | `tasks:write` | Aufgabe anlegen (`content`, `taskListId`, `priority`, `dueDate`, `notes`); Quelle `agent` | | `update_task` | `tasks:write` | Felder ändern (`content`, `isCompleted`, `dueDate`, `priority`, `notes`, `kanbanStatus`, `taskListId`, `tags`) | | `complete_task` | `tasks:write` | Abhaken (`completed` false = wieder öffnen) | +| `delete_task` | `tasks:write` | Aufgabe endgültig löschen (z. B. versehentlich angelegte) | | `list_lists` | `lists:read` | Geteilte Listen mit offenen Einträgen | +| `create_list` | `lists:write` | Liste anlegen (`title`, `icon`, `listType` shopping/checklist/vacation) | | `get_list` | `lists:read` | Liste mit Einträgen | | `add_list_item` | `lists:write` | Eintrag hinzufügen (`listId`, `content`, `category`, `quantity`) | | `check_list_item` | `lists:write` | Eintrag abhaken (`completed` false = zurück) | @@ -98,6 +101,8 @@ MindSift ist ein [MCP](https://modelcontextprotocol.io)-Server (Streamable HTTP, | `search` | ein Lese-Scope | Volltextsuche in Aufgaben und Listeneinträgen (nur die Teile der eigenen Scopes, keine Notizen) | | `capture_to_inbox` | `webhook:triage` | Gedanken in die Inbox | +Was ein Agent anlegt oder ändert, bekommt das Feld `agentMark` (`created` bzw. `edited`) an Aufgabe oder Listeneintrag; die App zeigt dafür das Zeichen „KI“ bzw. „KI geändert“. Ändert die Person den Eintrag selbst (REST, App), fällt die Markierung weg. Ein Agent, der einen eigenen Eintrag ändert, lässt `created` stehen. + Freigaben gelten wie für die Person selbst: Leser-Listen lassen sich nicht ändern, fremde Listen sind unsichtbar. Einrichtung z. B. in Claude Code: `claude mcp add --transport http mindsift https://mindsift.example.com/api/v1/mcp --header "X-API-Key: mindsift_sec_…"`; die Einstellungen zeigen die Varianten für Codex (`config.toml`) und JSON-Konfigurationen (Claude Desktop, Cursor). claude.ai und ChatGPT im Browser verlangen OAuth und werden noch nicht unterstützt. ## Gedanken, Triage, Aufgaben diff --git a/Doku/modules/workspace.md b/Doku/modules/workspace.md index 5c06971..6dcedc5 100644 --- a/Doku/modules/workspace.md +++ b/Doku/modules/workspace.md @@ -66,7 +66,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts ## Export als Markdown-Archiv -Kontoeinstellungen → Webhooks, KI-Agenten & Export → „Markdown-Archiv herunterladen“ (`GET /export/markdown`, Code: [export_markdown.go](../../internal/storage/export_markdown.go)). +Kontoeinstellungen → Export & Import → „Markdown-Archiv herunterladen“ (`GET /export/markdown`, Code: [export_markdown.go](../../internal/storage/export_markdown.go)). - Ordner und Notizen so, wie der Nutzer sie sieht (eigene Ordner, eingehängte geteilte Ordner, mit ihm geteilte Notizen); leere Ordner bleiben erhalten. - Jede Notiz: YAML-Frontmatter (`title`, `icon`, `tags`, `created_at`, `updated_at`) und Markdown, das Obsidian direkt darstellt: Überschriften, Trenner, Hinweisboxen (`> [!note]`) und Codeblöcke der obersten Ebene als echte Blöcke, ihre Kinder rücken eine Ebene nach vorn; alles andere als Liste mit einem Tab pro Ebene, To-dos als `- [ ]`. @@ -75,7 +75,7 @@ Kontoeinstellungen → Webhooks, KI-Agenten & Export → „Markdown-Archiv heru ## Import -Kontoeinstellungen → Webhooks, KI-Agenten & Export → „Daten importieren“, mit **zwei getrennten Wegen** (Code: [import_markdown.go](../../internal/storage/import_markdown.go), [ImportCard.tsx](../../frontend/src/features/settings/ImportCard.tsx)): +Kontoeinstellungen → Export & Import → „Daten importieren“, mit **zwei getrennten Wegen** (Code: [import_markdown.go](../../internal/storage/import_markdown.go), [ImportCard.tsx](../../frontend/src/features/settings/ImportCard.tsx)): | Weg | Endpunkt | Dateien | Ergebnis | |---|---|---|---| diff --git a/Doku/ui-patterns.md b/Doku/ui-patterns.md index 4196daa..f38666a 100644 --- a/Doku/ui-patterns.md +++ b/Doku/ui-patterns.md @@ -63,6 +63,14 @@ Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Kla - Zahlen im mobilen Menü (`MobileActionContext`, `badge: number`) werden automatisch als `CountBadge` gezeigt; Text-Badges wie „2 aktiv“ bleiben Textmarken. - **Wichtig für neue Klassen:** Tailwind entfernt Klassen aus `@layer components`, die nicht wörtlich im Code stehen. Klassennamen deshalb nie zusammensetzen (`count-badge-${variant}`), sondern ausschreiben. +## Zeichen für Einträge von KI-Agenten + +Komponente: [AgentBadge.tsx](../frontend/src/components/AgentBadge.tsx) + +- Zeigt an Aufgaben (Zeile, Kanban-Karte, Detailansicht, Inbox) und Listeneinträgen, dass ein KI-Agent sie per MCP angelegt („KI“) oder zuletzt geändert hat („KI geändert“). Grundlage ist das Feld `agentMark` vom Server. +- Ändert oder hakt die Person den Eintrag selbst ab, löscht der Server die Markierung; das Zeichen verschwindet dann ohne eigenes Zutun des Frontends. +- Größen: `sm` in Aufgabenzeilen und der Detailansicht, `xs` auf Kanban-Karten und in Listen. Kein Hashtag als Ersatz. + ## Ladebildschirm & Statusleiste - `manifest.json`: `name` „MindSift“ (kurz, der Slogan steht in `description`), `background_color` und `theme_color` `#2563eb` – Android baut daraus den Splash-Screen. diff --git a/README.md b/README.md index 61eea4e..e8cc725 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) -[![Version](https://img.shields.io/badge/Version-v0.28.0-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.29.0-16a34a?style=flat-square)](CHANGELOG.md) **MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server. @@ -129,7 +129,7 @@ Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md) ### 🔌 Integrationen * **Eingehend (API-Key):** `POST /api/v1/webhooks/ingest` legt Gedanken, Aufgaben oder Listeneinträge aus Smarthome, Skripten oder Automationen an. -* **KI-Agenten (MCP):** MindSift ist ein MCP-Server (`POST /api/v1/mcp`, Streamable HTTP). Claude Code, OpenAI Codex, Claude Desktop, Cursor und eigene Agenten lesen offene Aufgaben, haken sie ab, legen neue an und führen Einkaufs- und Checklisten. Pro API-Key schaltbar (lesen/ändern, Aufgaben/Listen); Notizen bleiben außen vor. `claude mcp add --transport http mindsift https://…/api/v1/mcp --header "X-API-Key: mindsift_sec_…"` +* **KI-Agenten (MCP):** MindSift ist ein MCP-Server (`POST /api/v1/mcp`, Streamable HTTP). Claude Code, OpenAI Codex, Claude Desktop, Cursor und eigene Agenten lesen offene Aufgaben, haken sie ab, legen neue an oder löschen sie, legen Aufgabenlisten und Listen an und führen Einkaufs- und Checklisten. Was ein Agent angelegt oder geändert hat, trägt das Zeichen „KI“, bis du es selbst änderst. Pro API-Key schaltbar (lesen/ändern, Aufgaben/Listen); Notizen bleiben außen vor. `claude mcp add --transport http mindsift https://…/api/v1/mcp --header "X-API-Key: mindsift_sec_…"` * **Ausgehend:** eigener Webhook als Benachrichtigungskanal (JSON per POST), Discord, E-Mail, Push. * Persönliche API-Keys, Datenexport als JSON, Markdown-Archiv (Obsidian-kompatibel). diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c5a9b4a..6f2201e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.28.0", + "version": "0.29.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.28.0", + "version": "0.29.0", "license": "Unlicense", "dependencies": { "@fontsource-variable/inter": "5.3.0", diff --git a/frontend/package.json b/frontend/package.json index 6377b2d..05a9b56 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.28.0", + "version": "0.29.0", "license": "Unlicense", "type": "module", "scripts": { diff --git a/frontend/src/components/AgentBadge.tsx b/frontend/src/components/AgentBadge.tsx new file mode 100644 index 0000000..4d1e390 --- /dev/null +++ b/frontend/src/components/AgentBadge.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { Bot } from 'lucide-react'; +import { AgentMark } from '../lib/types'; + +interface AgentBadgeProps { + mark: AgentMark | undefined; + // sm: task rows and the task detail; xs: Kanban cards and list items + size?: 'sm' | 'xs'; +} + +const TITLES: Record = { + created: 'Von einem KI-Agenten per MCP angelegt. Das Zeichen verschwindet, sobald du den Eintrag selbst änderst.', + edited: 'Zuletzt von einem KI-Agenten per MCP geändert. Das Zeichen verschwindet, sobald du den Eintrag selbst änderst.', +}; + +// Marks entries an AI agent created or changed via MCP that the user has not touched since +// (Doku/ui-patterns.md) +export const AgentBadge: React.FC = ({ mark, size = 'sm' }) => { + if (mark !== 'created' && mark !== 'edited') return null; + const label = mark === 'created' ? 'KI' : 'KI geändert'; + return ( + + + {label} + + ); +}; diff --git a/frontend/src/components/__tests__/AgentBadge.test.tsx b/frontend/src/components/__tests__/AgentBadge.test.tsx new file mode 100644 index 0000000..3ac4b45 --- /dev/null +++ b/frontend/src/components/__tests__/AgentBadge.test.tsx @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import { AgentBadge } from '../AgentBadge'; + +describe('AgentBadge', () => { + it('shows nothing for entries of the user', () => { + expect(render().container.firstChild).toBeNull(); + }); + + it('tells created and edited entries apart', () => { + const created = render().container.firstChild as HTMLElement; + expect(created.textContent).toBe('KI'); + expect(created.getAttribute('title')).toContain('angelegt'); + const edited = render().container.firstChild as HTMLElement; + expect(edited.textContent).toBe('KI geändert'); + expect(edited.getAttribute('title')).toContain('geändert'); + }); +}); diff --git a/frontend/src/features/help/HelpView.tsx b/frontend/src/features/help/HelpView.tsx index f666ee9..82c3b10 100644 --- a/frontend/src/features/help/HelpView.tsx +++ b/frontend/src/features/help/HelpView.tsx @@ -513,10 +513,10 @@ export const HelpView: React.FC = ({ onBack }) => { content: (

- Unter Kontoeinstellungen → Webhooks, KI-Agenten & Export kannst du persönliche API-Schlüssel erzeugen. Pro Schlüssel legst du mit Schaltern fest, was er darf: Gedanken in die Inbox, Aufgaben oder Listeneinträge einliefern (Webhooks) oder für KI-Agenten Aufgaben und Listen lesen und ändern. Notizen erreicht ein Schlüssel nie. + Unter Kontoeinstellungen → Webhooks & KI-Agenten kannst du persönliche API-Schlüssel erzeugen. Pro Schlüssel legst du mit Schaltern fest, was er darf: Gedanken in die Inbox, Aufgaben oder Listeneinträge einliefern (Webhooks) oder für KI-Agenten Aufgaben und Listen lesen und ändern. Notizen erreicht ein Schlüssel nie.

- KI-Agenten: MindSift ist ein MCP-Server (POST /api/v1/mcp). Claude Code, OpenAI Codex, Claude Desktop, Cursor und eigene Agenten können damit offene Aufgaben auslesen, abhaken und anlegen sowie Einkaufs- und Checklisten führen. Die Einstellungen zeigen den fertigen Befehl bzw. die Konfiguration für den jeweiligen Agenten; der Agent sieht nur die Werkzeuge, für die der Schlüssel freigegeben ist. + KI-Agenten: MindSift ist ein MCP-Server (POST /api/v1/mcp). Claude Code, OpenAI Codex, Claude Desktop, Cursor und eigene Agenten können damit offene Aufgaben auslesen, anlegen, abhaken und löschen, neue Aufgabenlisten und Listen anlegen sowie Einkaufs- und Checklisten führen. Die Einstellungen zeigen den fertigen Befehl bzw. die Konfiguration für den jeweiligen Agenten; der Agent sieht nur die Werkzeuge, für die der Schlüssel freigegeben ist. Was ein Agent angelegt oder geändert hat, trägt das Zeichen KI bzw. KI geändert, bis du den Eintrag selbst änderst.

Mit dem Feld "target" wählst du das Ziel: triage (Standard), task (optional mit taskListId) oder list_item (mit listId). Der Simulator in den Einstellungen zeigt passende Beispiele. @@ -664,7 +664,7 @@ export const HelpView: React.FC = ({ onBack }) => { Eigenes Theme: Vorlage herunterladen oder einfügen, Farben und Rundungen anpassen, als .css-Datei hochladen oder einfügen und „Speichern & aktivieren“. Das Theme greift sofort und steht dir auf allen Geräten zur Verfügung.

  • - Server-Backups: Hat die Administration automatische Backups eingeschaltet, findest du unter Kontoeinstellungen → Webhooks, KI-Agenten & Export die täglichen Sicherungen deiner Daten. Du kannst sie herunterladen oder wieder einspielen, auch nur für einzelne Bereiche wie Listen oder Aufgaben. + Server-Backups: Hat die Administration automatische Backups eingeschaltet, findest du unter Kontoeinstellungen → Export & Import die täglichen Sicherungen deiner Daten. Du kannst sie herunterladen oder wieder einspielen, auch nur für einzelne Bereiche wie Listen oder Aufgaben.
  • Ohne Internet: Als App installiert (oder im Browser) startet MindSift auch offline mit dem zuletzt geladenen Stand. Gedanken erfassen sowie Aufgaben und Listeneinträge abhaken geht weiter; ein Hinweis unten zeigt, wie viele Änderungen warten, und sie werden automatisch übertragen, sobald die Verbindung zurück ist. diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index 607817b..1d441a9 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -16,6 +16,7 @@ import { OFFLINE_ITEM_PREFIX, pendingListItems } from '../../lib/offline'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { AvatarStack, AvatarPerson } from '../../components/AvatarStack'; import { CountBadge } from '../../components/CountBadge'; +import { AgentBadge } from '../../components/AgentBadge'; import { FormSheet } from '../../components/FormSheet'; import { useMobileActions } from '../../context/MobileActionContext'; @@ -1390,6 +1391,7 @@ export const SharedListsView: React.FC = ({ onRefreshGloba )}
  • + {renderItemAuthor(item)} + + @@ -1849,14 +1865,12 @@ export const UserSettingsView: React.FC = ({ )} {/* ================================================================= */} - {/* SECTION 4: PRIVATE WEBHOOKS & EXPORT */} + {/* SECTION 4: PRIVATE WEBHOOKS & AI AGENTS */} {/* ================================================================= */} {section === 'webhooks' && (
    -

    - Webhooks, KI-Agenten & Daten-Export -

    +

    Webhooks & KI-Agenten

    Erstelle persönliche API-Schlüssel, um aus Skripten, Smarthome oder Bots Aufgaben und Gedanken einzuspeisen oder KI-Agenten wie Claude Code und Codex an deine Aufgaben und Listen zu lassen. @@ -1864,11 +1878,24 @@ export const UserSettingsView: React.FC = ({

    +
    + )} -
    - + {/* ================================================================= */} + {/* SECTION 5: EXPORT & IMPORT */} + {/* ================================================================= */} + {section === 'data' && ( +
    +
    +

    Export & Import

    +

    + Lade deine Daten als Backup oder Markdown-Archiv herunter, spiele Server-Backups zurück oder importiere + MindSift-Daten und Obsidian-Vaults. +

    + + {/* Full Data Backup */}
    diff --git a/frontend/src/features/triage/TaskDetailModal.tsx b/frontend/src/features/triage/TaskDetailModal.tsx index 30c318d..c9f48dc 100644 --- a/frontend/src/features/triage/TaskDetailModal.tsx +++ b/frontend/src/features/triage/TaskDetailModal.tsx @@ -20,6 +20,7 @@ import { } from 'lucide-react'; import { Thought, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types'; import { UserAvatar } from '../../components/UserAvatar'; +import { AgentBadge } from '../../components/AgentBadge'; import { UserAutocomplete } from '../UserAutocomplete'; import { api, triggerHaptic } from '../../lib/api'; import { MarkdownViewer } from '../../lib/markdown'; @@ -244,6 +245,7 @@ const TaskDetailModalContent: React.FC Webhook )} +
    diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index 8e78900..0508047 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -35,6 +35,7 @@ import { useKanbanTouchDrag } from './useKanbanTouchDrag'; import { api, triggerHaptic } from '../../lib/api'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { TaskDetailModal } from './TaskDetailModal'; +import { AgentBadge } from '../../components/AgentBadge'; import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal'; import { AvatarStack, AvatarPerson } from '../../components/AvatarStack'; import { CountBadge } from '../../components/CountBadge'; @@ -287,7 +288,7 @@ export const TasksView: React.FC = ({ } } else if (msg.event === 'TASK_REORDERED') { loadData(); - } else if (msg.event === 'TASK_LIST_UPDATED' || msg.event === 'TASK_LIST_DELETED') { + } else if (msg.event === 'TASK_LIST_CREATED' || msg.event === 'TASK_LIST_UPDATED' || msg.event === 'TASK_LIST_DELETED') { // Payload only carries the list ID; reload so permissions reflect this user's view api.getTaskLists().then((lists) => setTaskLists(Array.isArray(lists) ? lists : [])).catch(() => {}); } @@ -1352,6 +1353,7 @@ export const TasksView: React.FC = ({ Webhook )} + {/* Note indicator */} {task.notes && ( @@ -2435,6 +2437,7 @@ export const TasksView: React.FC = ({ Webhook )} + {task.dueDate && ( = ({ Diktat )} +
    {/* Desktop quick buttons (hidden on mobile so queue items are clean & easy to tap/swipe) */} @@ -521,6 +523,7 @@ export const TriageDeck: React.FC = ({
    {priorityBadge(nextCard.priority)} {sourceBadge(nextCard.source)} +

    {nextCard.content}

    Nächster Quick Dump…
    @@ -590,6 +593,7 @@ export const TriageDeck: React.FC = ({
    {priorityBadge(activeCard.priority)} {sourceBadge(activeCard.source)} +
    , string> = { yearly: 'jährlich', }; +// Set while the last change came from an AI agent via MCP and the user has not touched the entry since +export type AgentMark = 'created' | 'edited'; + export interface Thought { id: string; userId: string; content: string; tags: string[]; priority: PriorityLevel; - source: 'manual' | 'voice' | 'webhook'; + source: 'manual' | 'voice' | 'webhook' | 'agent'; status: ThoughtStatus; isCompleted: boolean; convertedType?: 'task' | 'document' | 'list_item' | 'document_block'; @@ -173,6 +176,7 @@ export interface Thought { sortOrder?: number; createdAt: string; triagedAt?: string; + agentMark?: AgentMark; } export interface KanbanColumn { @@ -538,6 +542,7 @@ export interface ListItem { sortOrder: number; createdAt: string; completedAt?: string; + agentMark?: AgentMark; } export interface ListShare { diff --git a/internal/api/handlers_integrations.go b/internal/api/handlers_integrations.go index 983be2b..7757a2a 100644 --- a/internal/api/handlers_integrations.go +++ b/internal/api/handlers_integrations.go @@ -226,7 +226,7 @@ var errListIDRequired = errors.New("Feld 'listId' ist für Listeneinträge erfor // addListItem appends an item to a list the user may write to; the error is storage.ErrListReadOnly for // viewers and "not found" for lists the user cannot see. -func (s *Server) addListItem(userID, listID, content, category, quantity string) (*domain.ListItem, error) { +func (s *Server) addListItem(userID, listID, content, category, quantity string, byAgent bool) (*domain.ListItem, error) { if strings.TrimSpace(listID) == "" { return nil, errListIDRequired } @@ -237,13 +237,16 @@ func (s *Server) addListItem(userID, listID, content, category, quantity string) if err != nil { return nil, err } + if byAgent { + item.AgentMark = s.store.MarkListItemAgentCreated(item.ID) + } s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item) return item, nil } // ingestListItem adds an item to a list (scope webhook:list) the owner of the key may write to. func (s *Server) ingestListItem(w http.ResponseWriter, userID, listID, content, category, quantity string) { - item, err := s.addListItem(userID, listID, content, category, quantity) + item, err := s.addListItem(userID, listID, content, category, quantity, false) if err != nil { status := http.StatusNotFound switch { diff --git a/internal/api/handlers_lists.go b/internal/api/handlers_lists.go index bbb8207..f372915 100644 --- a/internal/api/handlers_lists.go +++ b/internal/api/handlers_lists.go @@ -167,8 +167,8 @@ func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) { } // setListItemDone checks an item off (or back on) and tells the list's viewers. Shared by -// PATCH /lists/{id}/items/{itemId} and the MCP tool check_list_item; access is checked first. -func (s *Server) setListItemDone(userID, listID, itemID string, done bool) (*domain.ListItem, error) { +// PATCH /lists/{id}/items/{itemId} and the MCP tool check_list_item (byAgent); access is checked first. +func (s *Server) setListItemDone(userID, listID, itemID string, done, byAgent bool) (*domain.ListItem, error) { if err := s.store.EnsureListItemsWritable(userID, listID, itemID); err != nil { return nil, err } @@ -181,6 +181,7 @@ func (s *Server) setListItemDone(userID, listID, itemID string, done bool) (*dom if err != nil { return nil, err } + item.AgentMark = s.store.SetListItemAgentMark(itemID, byAgent) s.wsHub.BroadcastListEvent(listID, "ITEM_TOGGLED", item) return item, nil } @@ -201,7 +202,7 @@ func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) { return } - item, err := s.setListItemDone(userID, listID, itemID, req.IsCompleted) + item, err := s.setListItemDone(userID, listID, itemID, req.IsCompleted, false) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return @@ -231,6 +232,7 @@ func (s *Server) handleUpdateListItem(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } + item.AgentMark = s.store.SetListItemAgentMark(itemID, false) s.wsHub.BroadcastListEvent(listID, "ITEM_UPDATED", item) writeJSON(w, http.StatusOK, item) diff --git a/internal/api/handlers_thoughts.go b/internal/api/handlers_thoughts.go index ca67d51..52b4d93 100644 --- a/internal/api/handlers_thoughts.go +++ b/internal/api/handlers_thoughts.go @@ -245,6 +245,8 @@ type taskPatch struct { Tags *[]string `json:"tags"` Assignees *[]string `json:"assignees"` Recurrence *string `json:"recurrence"` + // byAgent: the change comes from an AI agent (MCP) and marks the task; otherwise the mark goes + byAgent bool } // updateTask applies a patch, keeps a linked note block in step, tells everyone who sees the task @@ -261,6 +263,7 @@ func (s *Server) updateTask(userID, thoughtID string, req taskPatch) (*domain.Th if err != nil { return nil, err } + updated.AgentMark = s.store.SetThoughtAgentMark(thoughtID, updated.AgentMark, req.byAgent) if req.Assignees != nil { assignees, err := s.store.SetTaskAssignees(userID, thoughtID, *req.Assignees) if err != nil { @@ -305,23 +308,30 @@ func (s *Server) handleUnlinkEnhancedTask(w http.ResponseWriter, r *http.Request writeJSON(w, http.StatusOK, map[string]bool{"unlinked": true}) } -func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) { - userID := auth.GetUserID(r.Context()) - thoughtID := chi.URLParam(r, "id") +// deleteTask removes a task, tells everyone who saw it and notifies its watchers. Shared by +// DELETE /thoughts/{id} and the MCP tool delete_task. +func (s *Server) deleteTask(userID, thoughtID string) error { th, err := s.store.GetThoughtByID(userID, thoughtID) if err == nil && th.TaskListID != "" && !s.store.CanEditTaskList(userID, th.TaskListID) { - writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error()) - return + return storage.ErrTaskListReadOnly } audience := s.taskAudience(th) if err := s.store.DeleteThought(userID, thoughtID); err != nil { - writeStoreError(w, http.StatusInternalServerError, err) - return + return err } if th != nil { s.notifyTaskChange(userID, th, nil, true) } s.wsHub.BroadcastToUsers(audience, "TASK_DELETED", map[string]string{"id": thoughtID}) + return nil +} + +func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + if err := s.deleteTask(userID, chi.URLParam(r, "id")); err != nil { + writeStoreError(w, http.StatusInternalServerError, err) + return + } writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } diff --git a/internal/api/mcp.go b/internal/api/mcp.go index 7ad9a02..6994644 100644 --- a/internal/api/mcp.go +++ b/internal/api/mcp.go @@ -128,7 +128,8 @@ func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) { "serverInfo": map[string]string{"name": "MindSift", "version": mcpServerVersion}, "instructions": "MindSift is a personal notes, tasks and lists app. Use list_task_lists and list_tasks " + "to see open tasks, complete_task to check one off, list_lists/get_list for shopping and other " + - "lists and check_list_item to tick items. IDs come from the list tools; never guess them.", + "lists and check_list_item to tick items. IDs come from the list tools; never guess them. " + + "Entries you create or change are marked for the user until they touch them.", }, nil) case "ping": writeRPC(w, req.ID, map[string]interface{}{}, nil) @@ -275,11 +276,31 @@ func toTaskView(t *domain.Thought) taskView { } type taskListView struct { - ID string `json:"id"` - Title string `json:"title"` - Icon string `json:"icon,omitempty"` - Permission string `json:"permission"` - OpenTasks int `json:"openTasks"` + ID string `json:"id"` + Title string `json:"title"` + Icon string `json:"icon,omitempty"` + Permission string `json:"permission"` + OpenTasks int `json:"openTasks"` + Columns []columnView `json:"columns,omitempty"` +} + +// columnView: a board column, so an agent knows the valid kanbanStatus values of a task list +type columnView struct { + ID string `json:"id"` + Name string `json:"name"` + IsDone bool `json:"isDone,omitempty"` +} + +func toTaskListView(l domain.TaskList) taskListView { + cols := make([]columnView, 0, len(l.Columns)) + for _, c := range l.Columns { + cols = append(cols, columnView{ID: c.ID, Name: c.Name, IsDone: c.IsDone}) + } + permission := l.Permission + if permission == "" && l.IsOwner { + permission = "owner" + } + return taskListView{ID: l.ID, Title: l.Title, Icon: l.Icon, Permission: permission, OpenTasks: l.TasksCount, Columns: cols} } type listView struct { @@ -309,7 +330,7 @@ func toListView(l domain.SharedList) listView { var mcpTools = []mcpTool{ { Name: "list_task_lists", - Description: "All task lists the user can see, with permission (owner/editor/viewer) and the number of open tasks. Tasks without a list live in the personal inbox list (taskListId empty).", + Description: "All task lists the user can see, with permission (owner/editor/viewer), the number of open tasks and the board columns (valid kanbanStatus values). Tasks without a list live in the personal inbox list (taskListId empty).", Scope: storage.ScopeTasksRead, Schema: schema(nil, map[string]interface{}{}), run: func(s *Server, userID string, _ json.RawMessage) (interface{}, error) { @@ -319,11 +340,39 @@ var mcpTools = []mcpTool{ } out := make([]taskListView, 0, len(lists)) for _, l := range lists { - out = append(out, taskListView{ID: l.ID, Title: l.Title, Icon: l.Icon, Permission: l.Permission, OpenTasks: l.TasksCount}) + out = append(out, toTaskListView(l)) } return out, nil }, }, + { + Name: "create_task_list", + Description: "Create a personal task list (board with the default columns). Returns it with its id and columns.", + Scope: storage.ScopeTasksWrite, + Schema: schema([]string{"title"}, map[string]interface{}{ + "title": prop("string", "Name of the task list"), + "icon": prop("string", "One emoji, optional (default 📋)"), + }), + run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) { + var a struct { + Title string `json:"title"` + Icon string `json:"icon"` + } + if err := decodeArgs(args, &a); err != nil { + return nil, err + } + if strings.TrimSpace(a.Title) == "" { + return nil, errors.New("'title' darf nicht leer sein") + } + tl, err := s.store.CreateTaskList(userID, "", "", a.Title, a.Icon, "") + if err != nil { + return nil, err + } + s.broadcastTaskListChange(tl.ID, nil, "TASK_LIST_CREATED") + tl.IsOwner = true + return toTaskListView(*tl), nil + }, + }, { Name: "list_tasks", Description: "Tasks the user can see. Defaults to open tasks across all lists; filter by taskListId, status (open|done|all) or dueBefore (YYYY-MM-DD, inclusive). Sorted: overdue and due first.", @@ -458,6 +507,7 @@ var mcpTools = []mcpTool{ return nil, err } a.taskPatch.Assignees, a.taskPatch.Recurrence, a.taskPatch.Subtasks = nil, nil, nil + a.taskPatch.byAgent = true t, err := s.updateTask(userID, a.ID, a.taskPatch) if err != nil { return nil, err @@ -482,13 +532,34 @@ var mcpTools = []mcpTool{ return nil, err } done := a.Completed == nil || *a.Completed - t, err := s.updateTask(userID, a.ID, taskPatch{IsCompleted: &done}) + t, err := s.updateTask(userID, a.ID, taskPatch{IsCompleted: &done, byAgent: true}) if err != nil { return nil, err } return toTaskView(t), nil }, }, + { + Name: "delete_task", + Description: "Delete a task for good, e.g. one created by mistake. Prefer complete_task for work that is done.", + Scope: storage.ScopeTasksWrite, + Schema: schema([]string{"id"}, map[string]interface{}{"id": prop("string", "Task id")}), + run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) { + var a struct { + ID string `json:"id"` + } + if err := decodeArgs(args, &a); err != nil { + return nil, err + } + if _, err := s.store.GetThoughtByID(userID, a.ID); err != nil { + return nil, errors.New("Aufgabe nicht gefunden") + } + if err := s.deleteTask(userID, a.ID); err != nil { + return nil, err + } + return map[string]interface{}{"id": a.ID, "deleted": true}, nil + }, + }, { Name: "list_lists", Description: "Shopping lists, checklists and other shared lists the user can see, with open/total item counts.", @@ -506,6 +577,35 @@ var mcpTools = []mcpTool{ return out, nil }, }, + { + Name: "create_list", + Description: "Create a list for the user: shopping (items grouped by category), checklist or vacation (packing list).", + Scope: storage.ScopeListsWrite, + Schema: schema([]string{"title"}, map[string]interface{}{ + "title": prop("string", "Name of the list"), + "icon": prop("string", "One emoji, optional"), + "listType": map[string]interface{}{"type": "string", "enum": []string{"shopping", "checklist", "vacation"}, "description": "Default shopping"}, + }), + run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) { + var a struct { + Title string `json:"title"` + Icon string `json:"icon"` + ListType string `json:"listType"` + } + if err := decodeArgs(args, &a); err != nil { + return nil, err + } + if strings.TrimSpace(a.Title) == "" { + return nil, errors.New("'title' darf nicht leer sein") + } + l, err := s.store.CreateList(userID, a.Title, a.Icon, a.ListType) + if err != nil { + return nil, err + } + s.wsHub.BroadcastListEvent(l.ID, "LIST_CREATED", l) + return toListView(*l), nil + }, + }, { Name: "get_list", Description: "One list with all its items (id, content, category, quantity, isCompleted).", @@ -545,7 +645,7 @@ var mcpTools = []mcpTool{ if strings.TrimSpace(a.Content) == "" { return nil, errors.New("'content' darf nicht leer sein") } - return s.addListItem(userID, a.ListID, a.Content, a.Category, a.Quantity) + return s.addListItem(userID, a.ListID, a.Content, a.Category, a.Quantity, true) }, }, { @@ -566,7 +666,7 @@ var mcpTools = []mcpTool{ if err := decodeArgs(args, &a); err != nil { return nil, err } - return s.setListItemDone(userID, a.ListID, a.ItemID, a.Completed == nil || *a.Completed) + return s.setListItemDone(userID, a.ListID, a.ItemID, a.Completed == nil || *a.Completed, true) }, }, { @@ -598,6 +698,7 @@ var mcpTools = []mcpTool{ if err != nil { return nil, err } + item.AgentMark = s.store.SetListItemAgentMark(a.ItemID, true) s.wsHub.BroadcastListEvent(a.ListID, "ITEM_UPDATED", item) return item, nil }, diff --git a/internal/api/mcp_test.go b/internal/api/mcp_test.go index f4714aa..26392b6 100644 --- a/internal/api/mcp_test.go +++ b/internal/api/mcp_test.go @@ -2,6 +2,7 @@ package api import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" "strings" @@ -88,7 +89,10 @@ func TestMCPToolsFollowScopes(t *testing.T) { // Read-only task key: lists tasks, cannot complete them readKey, _ := store.CreateAPIKey(claims.UserID, "Reader", []string{"tasks:read"}) - names := strings.Join(toolNames(func() map[string]interface{} { _, r := rpc(t, router, readKey.RawKey, `{"jsonrpc":"2.0","id":5,"method":"tools/list"}`); return r }()), ",") + names := strings.Join(toolNames(func() map[string]interface{} { + _, r := rpc(t, router, readKey.RawKey, `{"jsonrpc":"2.0","id":5,"method":"tools/list"}`) + return r + }()), ",") if !strings.Contains(names, "list_tasks") || strings.Contains(names, "complete_task") || strings.Contains(names, "list_lists") { t.Fatalf("tasks:read key sees the wrong tools: %s", names) } @@ -192,3 +196,82 @@ func TestMCPTasksAndLists(t *testing.T) { t.Fatalf("anonymous MCP call should be 401, got %d", rec.Code) } } + +// Agents can create task lists and lists and delete tasks; their entries carry a mark until the user +// changes them. +func TestMCPCreateDeleteAndAgentMark(t *testing.T) { + srv, store, token := setupTestServer(t) + router := srv.Router() + claims, _ := auth.ValidateToken(testJWTSecret, token) + key, _ := store.CreateAPIKey(claims.UserID, "Agent", []string{"tasks:read", "tasks:write", "lists:read", "lists:write"}) + call := func(id int, name, args string) map[string]interface{} { + t.Helper() + _, res := rpc(t, router, key.RawKey, fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"method":"tools/call","params":{"name":%q,"arguments":%s}}`, id, name, args)) + result, isErr := callResult(t, res) + if isErr { + t.Fatalf("%s failed: %v", name, result) + } + return result["structuredContent"].(map[string]interface{}) + } + + tl := call(1, "create_task_list", `{"title":"Agentenliste"}`) + tlID, _ := tl["id"].(string) + if tlID == "" || tl["permission"] != "owner" || len(tl["columns"].([]interface{})) == 0 { + t.Fatalf("create_task_list: %v", tl) + } + task := call(2, "create_task", `{"content":"Von Claude","taskListId":"`+tlID+`"}`) + taskID := task["id"].(string) + th, _ := store.GetThoughtByID(claims.UserID, taskID) + if th.AgentMark != "created" || th.TaskListID != tlID { + t.Fatalf("agent task should be marked created: %+v", th) + } + // An agent's change keeps "created"; the user's own change removes the mark + call(3, "update_task", `{"id":"`+taskID+`","priority":"high"}`) + if th, _ = store.GetThoughtByID(claims.UserID, taskID); th.AgentMark != "created" { + t.Fatalf("mark after agent edit: %q", th.AgentMark) + } + notes := "selbst ergänzt" + if _, err := srv.updateTask(claims.UserID, taskID, taskPatch{Notes: ¬es}); err != nil { + t.Fatal(err) + } + if th, _ = store.GetThoughtByID(claims.UserID, taskID); th.AgentMark != "" { + t.Fatalf("user edit should clear the mark, got %q", th.AgentMark) + } + call(4, "complete_task", `{"id":"`+taskID+`"}`) + if th, _ = store.GetThoughtByID(claims.UserID, taskID); th.AgentMark != "edited" { + t.Fatalf("agent edit of a user task should mark edited, got %q", th.AgentMark) + } + + call(5, "delete_task", `{"id":"`+taskID+`"}`) + if th, err := store.GetThoughtByID(claims.UserID, taskID); err == nil && th != nil { + t.Fatalf("task should be gone") + } + + l := call(6, "create_list", `{"title":"Packliste","listType":"vacation"}`) + listID := l["id"].(string) + if l["listType"] != "vacation" || l["role"] != "owner" { + t.Fatalf("create_list: %v", l) + } + item := call(7, "add_list_item", `{"listId":"`+listID+`","content":"Zahnbürste"}`) + if item["agentMark"] != "created" { + t.Fatalf("agent item should be marked created: %v", item) + } + items, _ := store.GetListItems(listID) + if len(items) != 1 || items[0].AgentMark != "created" { + t.Fatalf("stored item mark: %+v", items) + } + if _, err := srv.setListItemDone(claims.UserID, listID, items[0].ID, true, false); err != nil { + t.Fatal(err) + } + if items, _ = store.GetListItems(listID); items[0].AgentMark != "" { + t.Fatalf("ticking by the user should clear the mark: %q", items[0].AgentMark) + } + + // Other people's tasks cannot be deleted + other, _ := store.CreateUser("other@example.com", "hash", "Other", "member") + foreign, _ := store.CreateThoughtWithDetails(other.ID, "Fremd", "normal", "manual", "", "", "todo", "") + _, res := rpc(t, router, key.RawKey, `{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"delete_task","arguments":{"id":"`+foreign.ID+`"}}}`) + if _, isErr := callResult(t, res); !isErr { + t.Fatalf("key must not delete foreign tasks") + } +} diff --git a/internal/domain/models.go b/internal/domain/models.go index 63a6ab3..d1d7a0b 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -250,7 +250,7 @@ type Thought struct { Content string `json:"content"` Tags []string `json:"tags"` Priority string `json:"priority"` // low | normal | high | urgent - Source string `json:"source"` // manual | voice | webhook + Source string `json:"source"` // manual | voice | webhook | agent Status ThoughtStatus `json:"status"` IsCompleted bool `json:"isCompleted"` ConvertedType string `json:"convertedType,omitempty"` // task | document | list_item | document_block @@ -268,6 +268,7 @@ type Thought struct { SortOrder int `json:"sortOrder"` CreatedAt string `json:"createdAt"` TriagedAt string `json:"triagedAt,omitempty"` + AgentMark string `json:"agentMark,omitempty"` // created | edited: last change via MCP, untouched by the user since } type ThoughtReorderItem struct { @@ -570,6 +571,7 @@ type ListItem struct { SortOrder int `json:"sortOrder"` CreatedAt string `json:"createdAt"` CompletedAt string `json:"completedAt,omitempty"` + AgentMark string `json:"agentMark,omitempty"` // created | edited: last change via MCP, untouched by the user since } type SharedList struct { diff --git a/internal/storage/docsync.go b/internal/storage/docsync.go index e3f3c61..24007b6 100644 --- a/internal/storage/docsync.go +++ b/internal/storage/docsync.go @@ -483,7 +483,7 @@ func (s *Store) ReconcileDocTaskLinks(docID string) []string { // GetThoughtByIDUnchecked loads a task without access check, for server-internal notifications. func (s *Store) GetThoughtByIDUnchecked(thoughtID string) (*domain.Thought, error) { return scanThought(s.db.QueryRow( - `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence + `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark FROM thoughts WHERE id = ?`, thoughtID)) } diff --git a/internal/storage/lists.go b/internal/storage/lists.go index b12bed2..b9ec360 100644 --- a/internal/storage/lists.go +++ b/internal/storage/lists.go @@ -303,7 +303,7 @@ func (s *Store) DeleteList(userID, listID string) error { func (s *Store) GetListItems(listID string) ([]domain.ListItem, error) { rows, err := s.db.Query( `SELECT li.id, li.list_id, li.content, li.category, li.quantity, li.is_completed, li.completed_by, li.completed_by_name, - li.created_by, COALESCE(u.display_name, ''), li.sort_order, li.created_at, li.completed_at + li.created_by, COALESCE(u.display_name, ''), li.sort_order, li.created_at, li.completed_at, li.agent_mark FROM list_items li LEFT JOIN users u ON u.id = li.created_by AND li.created_by != '' WHERE li.list_id = ? ORDER BY li.is_completed ASC, li.sort_order ASC, li.created_at DESC`, @@ -318,7 +318,7 @@ func (s *Store) GetListItems(listID string) ([]domain.ListItem, error) { for rows.Next() { var it domain.ListItem var doneInt int - if err := rows.Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &doneInt, &it.CompletedBy, &it.CompletedByName, &it.CreatedBy, &it.CreatedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt); err == nil { + if err := rows.Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &doneInt, &it.CompletedBy, &it.CompletedByName, &it.CreatedBy, &it.CreatedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt, &it.AgentMark); err == nil { it.IsCompleted = doneInt != 0 items = append(items, it) } @@ -461,11 +461,11 @@ func (s *Store) MoveListItem(itemID, targetListID string) (*domain.ListItem, err var dInt int err := s.db.QueryRow( `SELECT li.id, li.list_id, li.content, li.category, li.quantity, li.is_completed, li.completed_by, li.completed_by_name, - li.created_by, COALESCE(u.display_name, ''), li.sort_order, li.created_at, li.completed_at + li.created_by, COALESCE(u.display_name, ''), li.sort_order, li.created_at, li.completed_at, li.agent_mark FROM list_items li LEFT JOIN users u ON u.id = li.created_by AND li.created_by != '' WHERE li.id = ?`, itemID, - ).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.CreatedBy, &it.CreatedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt) + ).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.CreatedBy, &it.CreatedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt, &it.AgentMark) if err != nil { return nil, err } diff --git a/internal/storage/migrations.go b/internal/storage/migrations.go index a1404bc..301018f 100644 --- a/internal/storage/migrations.go +++ b/internal/storage/migrations.go @@ -32,6 +32,7 @@ var migrations = []migration{ {9, "Web-Push", (*Store).migratePush}, {10, "Registrierung und Einladungslinks", (*Store).migrateRegistration}, {11, "E-Mail-Bestätigung", (*Store).migrateEmailVerification}, + {12, "Markierung für Einträge von KI-Agenten", (*Store).migrateAgentMark}, } func (s *Store) migrate() error { diff --git a/internal/storage/recurrence.go b/internal/storage/recurrence.go index 169e8ed..a2eb7bc 100644 --- a/internal/storage/recurrence.go +++ b/internal/storage/recurrence.go @@ -72,7 +72,7 @@ func (s *Store) SetTaskRecurrence(userID, taskID, rule string) error { func (s *Store) getThoughtRaw(id string) (*domain.Thought, error) { t, err := scanThought(s.db.QueryRow( - `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence + `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark FROM thoughts WHERE id = ?`, id)) if err != nil { return nil, err diff --git a/internal/storage/thoughts.go b/internal/storage/thoughts.go index eb177d2..b1837f4 100644 --- a/internal/storage/thoughts.go +++ b/internal/storage/thoughts.go @@ -76,11 +76,14 @@ func (s *Store) CreateThoughtWithDetails(userID, content, priority, source, dueD Notes: notes, CreatedAt: domain.NowISO(), } + if source == "agent" { + t.AgentMark = AgentMarkCreated + } _, err := s.db.Exec( - `INSERT INTO thoughts (id, user_id, content, tags, priority, source, status, is_completed, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, subtasks) - VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, 0, ?, '[]')`, - t.ID, t.UserID, t.Content, string(tagsJSON), t.Priority, t.Source, string(t.Status), t.DueDate, t.TaskListID, t.KanbanStatus, t.Notes, t.CreatedAt, + `INSERT INTO thoughts (id, user_id, content, tags, priority, source, status, is_completed, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, subtasks, agent_mark) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, 0, ?, '[]', ?)`, + t.ID, t.UserID, t.Content, string(tagsJSON), t.Priority, t.Source, string(t.Status), t.DueDate, t.TaskListID, t.KanbanStatus, t.Notes, t.CreatedAt, t.AgentMark, ) if err != nil { return nil, err @@ -99,7 +102,7 @@ func scanThought(rows interface { &t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source, &statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID, &t.DueDate, &t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent, - &t.CreatedAt, &t.TriagedAt, &subtasksStr, &sortOrder, &t.Recurrence, + &t.CreatedAt, &t.TriagedAt, &subtasksStr, &sortOrder, &t.Recurrence, &t.AgentMark, ) if err != nil { return nil, err @@ -136,7 +139,7 @@ func (s *Store) accessibleTaskListIDsQuery() string { func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) { row := s.db.QueryRow( - `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence + `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`, thoughtID, userID, userID, userID, userID, ) @@ -157,14 +160,14 @@ func (s *Store) GetThoughts(userID, statusFilter string, limit int) ([]domain.Th if statusFilter != "" && statusFilter != "all" { rows, err = s.db.Query( - `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence + `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark FROM thoughts WHERE user_id = ? AND status = ? ORDER BY CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, sort_order ASC, created_at DESC LIMIT ?`, userID, statusFilter, limit, ) } else { rows, err = s.db.Query( - `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence + `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark FROM thoughts WHERE user_id = ? ORDER BY sort_order ASC, created_at DESC LIMIT ?`, userID, limit, @@ -190,7 +193,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er var err error if taskListID != "" { rows, err = s.db.Query( - `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence + `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark FROM thoughts WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`)) AND task_list_id = ? @@ -200,7 +203,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er ) } else { rows, err = s.db.Query( - `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence + `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark FROM thoughts WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`)) AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task')) @@ -509,6 +512,59 @@ func (s *Store) DeleteThought(userID, thoughtID string) error { return err } +// migrateAgentMark: entries an AI agent created or changed via MCP and the user has not touched since +func (s *Store) migrateAgentMark() error { + if err := s.addColumnIfMissing("thoughts", "agent_mark", "TEXT NOT NULL DEFAULT ''"); err != nil { + return err + } + return s.addColumnIfMissing("list_items", "agent_mark", "TEXT NOT NULL DEFAULT ''") +} + +// Values of agent_mark on thoughts and list items +const ( + AgentMarkCreated = "created" + AgentMarkEdited = "edited" +) + +// nextAgentMark: an agent's change marks the entry (an entry it created stays "created"), a change by +// the user removes the mark. +func nextAgentMark(current string, byAgent bool) string { + if !byAgent { + return "" + } + if current == AgentMarkCreated { + return AgentMarkCreated + } + return AgentMarkEdited +} + +// SetThoughtAgentMark records who changed a task last and returns the new mark. +func (s *Store) SetThoughtAgentMark(thoughtID, current string, byAgent bool) string { + mark := nextAgentMark(current, byAgent) + if mark != current { + _, _ = s.db.Exec(`UPDATE thoughts SET agent_mark = ? WHERE id = ?`, mark, thoughtID) + } + return mark +} + +// SetListItemAgentMark is SetThoughtAgentMark for list items; the current mark is read here because +// the item queries after a change do not carry it. +func (s *Store) SetListItemAgentMark(itemID string, byAgent bool) string { + var current string + _ = s.db.QueryRow(`SELECT agent_mark FROM list_items WHERE id = ?`, itemID).Scan(¤t) + mark := nextAgentMark(current, byAgent) + if mark != current { + _, _ = s.db.Exec(`UPDATE list_items SET agent_mark = ? WHERE id = ?`, mark, itemID) + } + return mark +} + +// MarkListItemAgentCreated marks an item an agent just added. +func (s *Store) MarkListItemAgentCreated(itemID string) string { + _, _ = s.db.Exec(`UPDATE list_items SET agent_mark = ? WHERE id = ?`, AgentMarkCreated, itemID) + return AgentMarkCreated +} + func (s *Store) PurgeDiscardedThoughts(userID string) (int64, error) { res, err := s.db.Exec(`DELETE FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID) if err != nil {