feat(mcp): delete tasks, create task lists and lists, agent badge; export/import settings category
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m21s

New MCP tools delete_task (tasks:write), create_task_list (tasks:write) and create_list
(lists:write); list_task_lists now returns the board columns so agents know the valid
kanbanStatus values. Task deletion is a shared helper (deleteTask) for REST and MCP.

Tasks and list items get agent_mark (migration 12): "created" when an agent adds them via MCP,
"edited" when an agent changes them; a change by the user clears it. The new AgentBadge shows
"KI" / "KI geändert" in task rows, Kanban cards, the task detail, the inbox and lists.

Settings: export and import move out of "Webhooks, KI-Agenten & Export" into their own
category "Export & Import". Docs, help, README and changelog updated; v0.29.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-01 08:49:10 +01:00
1 parent 8faed7a160
commit ac068248d7
28 files changed
+437 -61

No files matched your search

+11 -1
View File
@@ -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
+6 -1
View File
@@ -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
+2 -2
View File
@@ -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 |
|---|---|---|---|
+8
View File
@@ -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.
+2 -2
View File
@@ -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).
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mindsift-frontend",
"private": true,
"version": "0.28.0",
"version": "0.29.0",
"license": "Unlicense",
"type": "module",
"scripts": {
+33
View File
@@ -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<AgentMark, string> = {
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<AgentBadgeProps> = ({ mark, size = 'sm' }) => {
if (mark !== 'created' && mark !== 'edited') return null;
const label = mark === 'created' ? 'KI' : 'KI geändert';
return (
<span
className={`px-1.5 border border-accent/40 bg-accent/10 text-accent rounded-btn font-medium inline-flex items-center gap-1 flex-shrink-0 ${
size === 'xs' ? 'py-0.2 text-[9px]' : 'py-0.5 text-[10px]'
}`}
title={TITLES[mark]}
aria-label={TITLES[mark]}
>
<Bot className="w-2.5 h-2.5" />
<span>{label}</span>
</span>
);
};
@@ -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(<AgentBadge mark={undefined} />).container.firstChild).toBeNull();
});
it('tells created and edited entries apart', () => {
const created = render(<AgentBadge mark="created" />).container.firstChild as HTMLElement;
expect(created.textContent).toBe('KI');
expect(created.getAttribute('title')).toContain('angelegt');
const edited = render(<AgentBadge mark="edited" />).container.firstChild as HTMLElement;
expect(edited.textContent).toBe('KI geändert');
expect(edited.getAttribute('title')).toContain('geändert');
});
});
+3 -3
View File
@@ -513,10 +513,10 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
content: (
<div className="space-y-2 text-muted leading-relaxed">
<p>
Unter <em className="text-main">Kontoeinstellungen → Webhooks, KI-Agenten &amp; Export</em> 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 <em className="text-main">Kontoeinstellungen → Webhooks &amp; KI-Agenten</em> 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.
</p>
<p>
<strong className="text-main">KI-Agenten:</strong> MindSift ist ein MCP-Server (<code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">POST /api/v1/mcp</code>). 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.
<strong className="text-main">KI-Agenten:</strong> MindSift ist ein MCP-Server (<code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">POST /api/v1/mcp</code>). 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 <strong className="text-main">KI</strong> bzw. <strong className="text-main">KI geändert</strong>, bis du den Eintrag selbst änderst.
</p>
<p>
Mit dem Feld <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">"target"</code> wählst du das Ziel: <code className="font-mono">triage</code> (Standard), <code className="font-mono">task</code> (optional mit <code className="font-mono">taskListId</code>) oder <code className="font-mono">list_item</code> (mit <code className="font-mono">listId</code>). Der Simulator in den Einstellungen zeigt passende Beispiele.
@@ -664,7 +664,7 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
<strong className="text-main">Eigenes Theme:</strong> Vorlage herunterladen oder einfügen, Farben und Rundungen anpassen, als <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">.css</code>-Datei hochladen oder einfügen und „Speichern &amp; aktivieren“. Das Theme greift sofort und steht dir auf allen Geräten zur Verfügung.
</li>
<li>
<strong className="text-main">Server-Backups:</strong> Hat die Administration automatische Backups eingeschaltet, findest du unter <em>Kontoeinstellungen → Webhooks, KI-Agenten &amp; Export</em> die täglichen Sicherungen deiner Daten. Du kannst sie herunterladen oder wieder einspielen, auch nur für einzelne Bereiche wie Listen oder Aufgaben.
<strong className="text-main">Server-Backups:</strong> Hat die Administration automatische Backups eingeschaltet, findest du unter <em>Kontoeinstellungen → Export &amp; Import</em> die täglichen Sicherungen deiner Daten. Du kannst sie herunterladen oder wieder einspielen, auch nur für einzelne Bereiche wie Listen oder Aufgaben.
</li>
<li>
<strong className="text-main">Ohne Internet:</strong> 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.
@@ -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<SharedListsViewProps> = ({ onRefreshGloba
)}
</div>
<AgentBadge mark={item.agentMark} size="xs" />
{renderItemAuthor(item)}
<button
type="button"
@@ -1499,6 +1501,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</div>
<div className="flex items-center gap-2">
<AgentBadge mark={item.agentMark} size="xs" />
{renderItemAuthor(item)}
{item.completedByName && (
<span className="text-xs text-faint">✓ {item.completedByName}</span>
@@ -35,7 +35,7 @@ type SimTarget = 'triage' | 'task' | 'list_item';
type McpClient = 'claude' | 'codex' | 'json';
// Personal API keys with scopes (switches), MCP connection snippets, a webhook simulator and copyable
// examples (settings → Webhooks, KI-Agenten & Export)
// examples (settings → Webhooks & KI-Agenten)
export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
const [keys, setKeys] = useState<APIKey[]>([]);
const [newKeyName, setNewKeyName] = useState('');
@@ -17,6 +17,7 @@ import {
Upload,
Pencil,
Copy,
Archive,
} from 'lucide-react';
import { AuthStatus, NotificationEvents, NotificationSettings, PushDevice, User, UserTheme } from '../../lib/types';
import { ApiKeysPanel } from './ApiKeysPanel';
@@ -65,7 +66,7 @@ interface UserSettingsViewProps {
onBack: () => void;
}
type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'webhooks';
type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'webhooks' | 'data';
export function buildNotificationSettings(user: User): NotificationSettings {
const hasLegacy = Array.isArray(user.alarmChannels);
@@ -582,7 +583,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<div>
<h1 className="text-base font-bold text-main tracking-tight">Kontoeinstellungen</h1>
<p className="text-xs text-muted">
Persönliches Profil, Design-Themes, Aufgaben-Alarme, Webhooks, KI-Agenten & Daten-Backup
Persönliches Profil, Design-Themes, Aufgaben-Alarme, Webhooks, KI-Agenten, Export & Import
</p>
</div>
</div>
@@ -651,8 +652,23 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
>
<Key className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Webhooks, KI-Agenten & Export</p>
<p className="text-[10px] text-faint">API-Keys, MCP, Curl & JSON-Backup</p>
<p className="font-semibold">Webhooks & KI-Agenten</p>
<p className="text-[10px] text-faint">API-Keys, MCP & Curl</p>
</div>
</button>
<button
onClick={() => setSection('data')}
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
section === 'data'
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
: 'text-muted hover:text-main hover:bg-panel-hover'
}`}
>
<Archive className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Export & Import</p>
<p className="text-[10px] text-faint">Backups, Obsidian & Wiederherstellen</p>
</div>
</button>
</aside>
@@ -1849,14 +1865,12 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
)}
{/* ================================================================= */}
{/* SECTION 4: PRIVATE WEBHOOKS & EXPORT */}
{/* SECTION 4: PRIVATE WEBHOOKS & AI AGENTS */}
{/* ================================================================= */}
{section === 'webhooks' && (
<div className="space-y-5">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-main">
Webhooks, KI-Agenten & Daten-Export
</h2>
<h2 className="text-sm font-bold text-main">Webhooks & KI-Agenten</h2>
<p className="text-muted mt-0.5">
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,10 +1878,23 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</div>
<ApiKeysPanel />
<div className="pt-4 border-t border-border">
<ServerBackupsCard />
</div>
)}
{/* ================================================================= */}
{/* SECTION 5: EXPORT & IMPORT */}
{/* ================================================================= */}
{section === 'data' && (
<div className="space-y-5">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-main">Export & Import</h2>
<p className="text-muted mt-0.5">
Lade deine Daten als Backup oder Markdown-Archiv herunter, spiele Server-Backups zurück oder importiere
MindSift-Daten und Obsidian-Vaults.
</p>
</div>
<ServerBackupsCard />
{/* Full Data Backup */}
<div className="pt-4 border-t border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
@@ -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<TaskDetailModalProps & { task: Thought }>
<span>Webhook</span>
</span>
)}
<AgentBadge mark={task.agentMark} />
</div>
<div className="flex items-center gap-1.5">
+4 -1
View File
@@ -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<TasksViewProps> = ({
}
} 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<TasksViewProps> = ({
<span>Webhook</span>
</span>
)}
<AgentBadge mark={task.agentMark} />
{/* Note indicator */}
{task.notes && (
@@ -2435,6 +2437,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
<span>Webhook</span>
</span>
)}
<AgentBadge mark={task.agentMark} size="xs" />
{task.dueDate && (
<span
className={`inline-flex items-center gap-0.5 text-[9px] ${!task.isCompleted && isOverdue(task.dueDate) ? 'text-status-err font-semibold' : 'text-muted'}`}
@@ -25,6 +25,7 @@ import {
import { SharedList, Thought } from '../../lib/types';
import { addDays, formatDue } from '../../lib/due';
import { api, triggerHaptic } from '../../lib/api';
import { AgentBadge } from '../../components/AgentBadge';
interface TriageDeckProps {
deck: Thought[];
@@ -158,6 +159,7 @@ const SwipeableQueueItem: React.FC<SwipeableQueueItemProps> = ({
<Mic className="w-2.5 h-2.5" /> Diktat
</span>
)}
<AgentBadge mark={item.agentMark} />
</div>
{/* Desktop quick buttons (hidden on mobile so queue items are clean & easy to tap/swipe) */}
@@ -521,6 +523,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
<div className="flex items-center gap-1.5">
{priorityBadge(nextCard.priority)}
{sourceBadge(nextCard.source)}
<AgentBadge mark={nextCard.agentMark} />
</div>
<p className="text-sm text-muted line-clamp-2">{nextCard.content}</p>
<div className="text-[11px] text-faint">Nächster Quick Dump…</div>
@@ -590,6 +593,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
<div className="flex items-center gap-1.5">
{priorityBadge(activeCard.priority)}
{sourceBadge(activeCard.source)}
<AgentBadge mark={activeCard.agentMark} />
</div>
<div
className="flex items-center gap-2"
+6 -1
View File
@@ -148,13 +148,16 @@ export const RECURRENCE_LABELS: Record<Exclude<Recurrence, ''>, 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 {
+5 -2
View File
@@ -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 {
+5 -3
View File
@@ -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)
+17 -7
View File
@@ -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})
}
+107 -6
View File
@@ -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)
@@ -280,6 +281,26 @@ type taskListView struct {
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
},
+84 -1
View File
@@ -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: &notes}); 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")
}
}
+3 -1
View File
@@ -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 {
+1 -1
View File
@@ -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))
}
+4 -4
View File
@@ -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
}
+1
View File
@@ -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 {
+1 -1
View File
@@ -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
+65 -9
View File
@@ -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(&current)
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 {