Merge dev into main (v0.29.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 26s

This commit is contained in:
sandrodach committed 2026-11-01 08:52:04 +01:00
commit 8f13cf6c9d
28 files changed
+441 -65

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] ## [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 ## [0.28.0] - 2026-10-31
### Added ### 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. - 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. - 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.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.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 [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 | | 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 | | `list_tasks` | `tasks:read` | Aufgaben (`taskListId`, `status` open/done/all, `dueBefore` JJJJ-MM-TT, `limit`); fällige zuerst |
| `get_task` | `tasks:read` | Eine Aufgabe | | `get_task` | `tasks:read` | Eine Aufgabe |
| `create_task` | `tasks:write` | Aufgabe anlegen (`content`, `taskListId`, `priority`, `dueDate`, `notes`); Quelle `agent` | | `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`) | | `update_task` | `tasks:write` | Felder ändern (`content`, `isCompleted`, `dueDate`, `priority`, `notes`, `kanbanStatus`, `taskListId`, `tags`) |
| `complete_task` | `tasks:write` | Abhaken (`completed` false = wieder öffnen) | | `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 | | `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 | | `get_list` | `lists:read` | Liste mit Einträgen |
| `add_list_item` | `lists:write` | Eintrag hinzufügen (`listId`, `content`, `category`, `quantity`) | | `add_list_item` | `lists:write` | Eintrag hinzufügen (`listId`, `content`, `category`, `quantity`) |
| `check_list_item` | `lists:write` | Eintrag abhaken (`completed` false = zurück) | | `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) | | `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 | | `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. 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 ## Gedanken, Triage, Aufgaben
+2 -2
View File
@@ -66,7 +66,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
## Export als Markdown-Archiv ## 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. - 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 `- [ ]`. - 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 ## 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 | | 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. - 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. - **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 ## Ladebildschirm & Statusleiste
- `manifest.json`: `name` „MindSift“ (kurz, der Slogan steht in `description`), `background_color` und `theme_color` `#2563eb` – Android baut daraus den Splash-Screen. - `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)](#) [![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) [![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) [![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. **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 ### 🔌 Integrationen
* **Eingehend (API-Key):** `POST /api/v1/webhooks/ingest` legt Gedanken, Aufgaben oder Listeneinträge aus Smarthome, Skripten oder Automationen an. * **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. * **Ausgehend:** eigener Webhook als Benachrichtigungskanal (JSON per POST), Discord, E-Mail, Push.
* Persönliche API-Keys, Datenexport als JSON, Markdown-Archiv (Obsidian-kompatibel). * Persönliche API-Keys, Datenexport als JSON, Markdown-Archiv (Obsidian-kompatibel).
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"version": "0.28.0", "version": "0.29.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"version": "0.28.0", "version": "0.29.0",
"license": "Unlicense", "license": "Unlicense",
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "5.3.0", "@fontsource-variable/inter": "5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"private": true, "private": true,
"version": "0.28.0", "version": "0.29.0",
"license": "Unlicense", "license": "Unlicense",
"type": "module", "type": "module",
"scripts": { "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: ( content: (
<div className="space-y-2 text-muted leading-relaxed"> <div className="space-y-2 text-muted leading-relaxed">
<p> <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>
<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>
<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. 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. <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>
<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>
<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. <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 { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack'; import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
import { CountBadge } from '../../components/CountBadge'; import { CountBadge } from '../../components/CountBadge';
import { AgentBadge } from '../../components/AgentBadge';
import { FormSheet } from '../../components/FormSheet'; import { FormSheet } from '../../components/FormSheet';
import { useMobileActions } from '../../context/MobileActionContext'; import { useMobileActions } from '../../context/MobileActionContext';
@@ -1390,6 +1391,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
)} )}
</div> </div>
<AgentBadge mark={item.agentMark} size="xs" />
{renderItemAuthor(item)} {renderItemAuthor(item)}
<button <button
type="button" type="button"
@@ -1499,6 +1501,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AgentBadge mark={item.agentMark} size="xs" />
{renderItemAuthor(item)} {renderItemAuthor(item)}
{item.completedByName && ( {item.completedByName && (
<span className="text-xs text-faint">✓ {item.completedByName}</span> <span className="text-xs text-faint">✓ {item.completedByName}</span>
@@ -35,7 +35,7 @@ type SimTarget = 'triage' | 'task' | 'list_item';
type McpClient = 'claude' | 'codex' | 'json'; type McpClient = 'claude' | 'codex' | 'json';
// Personal API keys with scopes (switches), MCP connection snippets, a webhook simulator and copyable // 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 }) => { export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
const [keys, setKeys] = useState<APIKey[]>([]); const [keys, setKeys] = useState<APIKey[]>([]);
const [newKeyName, setNewKeyName] = useState(''); const [newKeyName, setNewKeyName] = useState('');
@@ -17,6 +17,7 @@ import {
Upload, Upload,
Pencil, Pencil,
Copy, Copy,
Archive,
} from 'lucide-react'; } from 'lucide-react';
import { AuthStatus, NotificationEvents, NotificationSettings, PushDevice, User, UserTheme } from '../../lib/types'; import { AuthStatus, NotificationEvents, NotificationSettings, PushDevice, User, UserTheme } from '../../lib/types';
import { ApiKeysPanel } from './ApiKeysPanel'; import { ApiKeysPanel } from './ApiKeysPanel';
@@ -65,7 +66,7 @@ interface UserSettingsViewProps {
onBack: () => void; onBack: () => void;
} }
type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'webhooks'; type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'webhooks' | 'data';
export function buildNotificationSettings(user: User): NotificationSettings { export function buildNotificationSettings(user: User): NotificationSettings {
const hasLegacy = Array.isArray(user.alarmChannels); const hasLegacy = Array.isArray(user.alarmChannels);
@@ -582,7 +583,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<div> <div>
<h1 className="text-base font-bold text-main tracking-tight">Kontoeinstellungen</h1> <h1 className="text-base font-bold text-main tracking-tight">Kontoeinstellungen</h1>
<p className="text-xs text-muted"> <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> </p>
</div> </div>
</div> </div>
@@ -651,8 +652,23 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
> >
<Key className="w-4 h-4 text-accent flex-shrink-0" /> <Key className="w-4 h-4 text-accent flex-shrink-0" />
<div> <div>
<p className="font-semibold">Webhooks, KI-Agenten & Export</p> <p className="font-semibold">Webhooks & KI-Agenten</p>
<p className="text-[10px] text-faint">API-Keys, MCP, Curl & JSON-Backup</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> </div>
</button> </button>
</aside> </aside>
@@ -1849,14 +1865,12 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
)} )}
{/* ================================================================= */} {/* ================================================================= */}
{/* SECTION 4: PRIVATE WEBHOOKS & EXPORT */} {/* SECTION 4: PRIVATE WEBHOOKS & AI AGENTS */}
{/* ================================================================= */} {/* ================================================================= */}
{section === 'webhooks' && ( {section === 'webhooks' && (
<div className="space-y-5"> <div className="space-y-5">
<div className="border-b border-border pb-3"> <div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-main"> <h2 className="text-sm font-bold text-main">Webhooks & KI-Agenten</h2>
Webhooks, KI-Agenten & Daten-Export
</h2>
<p className="text-muted mt-0.5"> <p className="text-muted mt-0.5">
Erstelle persönliche API-Schlüssel, um aus Skripten, Smarthome oder Bots Aufgaben und Gedanken 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. 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<UserSettingsViewProps> = ({
</div> </div>
<ApiKeysPanel /> <ApiKeysPanel />
</div>
)}
<div className="pt-4 border-t border-border"> {/* ================================================================= */}
<ServerBackupsCard /> {/* 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> </div>
<ServerBackupsCard />
{/* Full Data Backup */} {/* Full Data Backup */}
<div className="pt-4 border-t border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3"> <div className="pt-4 border-t border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div> <div>
@@ -20,6 +20,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { Thought, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types'; import { Thought, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types';
import { UserAvatar } from '../../components/UserAvatar'; import { UserAvatar } from '../../components/UserAvatar';
import { AgentBadge } from '../../components/AgentBadge';
import { UserAutocomplete } from '../UserAutocomplete'; import { UserAutocomplete } from '../UserAutocomplete';
import { api, triggerHaptic } from '../../lib/api'; import { api, triggerHaptic } from '../../lib/api';
import { MarkdownViewer } from '../../lib/markdown'; import { MarkdownViewer } from '../../lib/markdown';
@@ -244,6 +245,7 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
<span>Webhook</span> <span>Webhook</span>
</span> </span>
)} )}
<AgentBadge mark={task.agentMark} />
</div> </div>
<div className="flex items-center gap-1.5"> <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 { api, triggerHaptic } from '../../lib/api';
import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { TaskDetailModal } from './TaskDetailModal'; import { TaskDetailModal } from './TaskDetailModal';
import { AgentBadge } from '../../components/AgentBadge';
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal'; import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal';
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack'; import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
import { CountBadge } from '../../components/CountBadge'; import { CountBadge } from '../../components/CountBadge';
@@ -287,7 +288,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
} }
} else if (msg.event === 'TASK_REORDERED') { } else if (msg.event === 'TASK_REORDERED') {
loadData(); 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 // Payload only carries the list ID; reload so permissions reflect this user's view
api.getTaskLists().then((lists) => setTaskLists(Array.isArray(lists) ? lists : [])).catch(() => {}); api.getTaskLists().then((lists) => setTaskLists(Array.isArray(lists) ? lists : [])).catch(() => {});
} }
@@ -1352,6 +1353,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
<span>Webhook</span> <span>Webhook</span>
</span> </span>
)} )}
<AgentBadge mark={task.agentMark} />
{/* Note indicator */} {/* Note indicator */}
{task.notes && ( {task.notes && (
@@ -2435,6 +2437,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
<span>Webhook</span> <span>Webhook</span>
</span> </span>
)} )}
<AgentBadge mark={task.agentMark} size="xs" />
{task.dueDate && ( {task.dueDate && (
<span <span
className={`inline-flex items-center gap-0.5 text-[9px] ${!task.isCompleted && isOverdue(task.dueDate) ? 'text-status-err font-semibold' : 'text-muted'}`} 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 { SharedList, Thought } from '../../lib/types';
import { addDays, formatDue } from '../../lib/due'; import { addDays, formatDue } from '../../lib/due';
import { api, triggerHaptic } from '../../lib/api'; import { api, triggerHaptic } from '../../lib/api';
import { AgentBadge } from '../../components/AgentBadge';
interface TriageDeckProps { interface TriageDeckProps {
deck: Thought[]; deck: Thought[];
@@ -158,6 +159,7 @@ const SwipeableQueueItem: React.FC<SwipeableQueueItemProps> = ({
<Mic className="w-2.5 h-2.5" /> Diktat <Mic className="w-2.5 h-2.5" /> Diktat
</span> </span>
)} )}
<AgentBadge mark={item.agentMark} />
</div> </div>
{/* Desktop quick buttons (hidden on mobile so queue items are clean & easy to tap/swipe) */} {/* 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"> <div className="flex items-center gap-1.5">
{priorityBadge(nextCard.priority)} {priorityBadge(nextCard.priority)}
{sourceBadge(nextCard.source)} {sourceBadge(nextCard.source)}
<AgentBadge mark={nextCard.agentMark} />
</div> </div>
<p className="text-sm text-muted line-clamp-2">{nextCard.content}</p> <p className="text-sm text-muted line-clamp-2">{nextCard.content}</p>
<div className="text-[11px] text-faint">Nächster Quick Dump…</div> <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"> <div className="flex items-center gap-1.5">
{priorityBadge(activeCard.priority)} {priorityBadge(activeCard.priority)}
{sourceBadge(activeCard.source)} {sourceBadge(activeCard.source)}
<AgentBadge mark={activeCard.agentMark} />
</div> </div>
<div <div
className="flex items-center gap-2" 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', 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 { export interface Thought {
id: string; id: string;
userId: string; userId: string;
content: string; content: string;
tags: string[]; tags: string[];
priority: PriorityLevel; priority: PriorityLevel;
source: 'manual' | 'voice' | 'webhook'; source: 'manual' | 'voice' | 'webhook' | 'agent';
status: ThoughtStatus; status: ThoughtStatus;
isCompleted: boolean; isCompleted: boolean;
convertedType?: 'task' | 'document' | 'list_item' | 'document_block'; convertedType?: 'task' | 'document' | 'list_item' | 'document_block';
@@ -173,6 +176,7 @@ export interface Thought {
sortOrder?: number; sortOrder?: number;
createdAt: string; createdAt: string;
triagedAt?: string; triagedAt?: string;
agentMark?: AgentMark;
} }
export interface KanbanColumn { export interface KanbanColumn {
@@ -538,6 +542,7 @@ export interface ListItem {
sortOrder: number; sortOrder: number;
createdAt: string; createdAt: string;
completedAt?: string; completedAt?: string;
agentMark?: AgentMark;
} }
export interface ListShare { 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 // 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. // 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) == "" { if strings.TrimSpace(listID) == "" {
return nil, errListIDRequired return nil, errListIDRequired
} }
@@ -237,13 +237,16 @@ func (s *Server) addListItem(userID, listID, content, category, quantity string)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if byAgent {
item.AgentMark = s.store.MarkListItemAgentCreated(item.ID)
}
s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item) s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item)
return item, nil return item, nil
} }
// ingestListItem adds an item to a list (scope webhook:list) the owner of the key may write to. // 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) { 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 { if err != nil {
status := http.StatusNotFound status := http.StatusNotFound
switch { 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 // 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. // 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 bool) (*domain.ListItem, error) { func (s *Server) setListItemDone(userID, listID, itemID string, done, byAgent bool) (*domain.ListItem, error) {
if err := s.store.EnsureListItemsWritable(userID, listID, itemID); err != nil { if err := s.store.EnsureListItemsWritable(userID, listID, itemID); err != nil {
return nil, err return nil, err
} }
@@ -181,6 +181,7 @@ func (s *Server) setListItemDone(userID, listID, itemID string, done bool) (*dom
if err != nil { if err != nil {
return nil, err return nil, err
} }
item.AgentMark = s.store.SetListItemAgentMark(itemID, byAgent)
s.wsHub.BroadcastListEvent(listID, "ITEM_TOGGLED", item) s.wsHub.BroadcastListEvent(listID, "ITEM_TOGGLED", item)
return item, nil return item, nil
} }
@@ -201,7 +202,7 @@ func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
return return
} }
item, err := s.setListItemDone(userID, listID, itemID, req.IsCompleted) item, err := s.setListItemDone(userID, listID, itemID, req.IsCompleted, false)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
@@ -231,6 +232,7 @@ func (s *Server) handleUpdateListItem(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
} }
item.AgentMark = s.store.SetListItemAgentMark(itemID, false)
s.wsHub.BroadcastListEvent(listID, "ITEM_UPDATED", item) s.wsHub.BroadcastListEvent(listID, "ITEM_UPDATED", item)
writeJSON(w, http.StatusOK, item) writeJSON(w, http.StatusOK, item)
+17 -7
View File
@@ -245,6 +245,8 @@ type taskPatch struct {
Tags *[]string `json:"tags"` Tags *[]string `json:"tags"`
Assignees *[]string `json:"assignees"` Assignees *[]string `json:"assignees"`
Recurrence *string `json:"recurrence"` 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 // 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 { if err != nil {
return nil, err return nil, err
} }
updated.AgentMark = s.store.SetThoughtAgentMark(thoughtID, updated.AgentMark, req.byAgent)
if req.Assignees != nil { if req.Assignees != nil {
assignees, err := s.store.SetTaskAssignees(userID, thoughtID, *req.Assignees) assignees, err := s.store.SetTaskAssignees(userID, thoughtID, *req.Assignees)
if err != nil { 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}) writeJSON(w, http.StatusOK, map[string]bool{"unlinked": true})
} }
func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) { // deleteTask removes a task, tells everyone who saw it and notifies its watchers. Shared by
userID := auth.GetUserID(r.Context()) // DELETE /thoughts/{id} and the MCP tool delete_task.
thoughtID := chi.URLParam(r, "id") func (s *Server) deleteTask(userID, thoughtID string) error {
th, err := s.store.GetThoughtByID(userID, thoughtID) th, err := s.store.GetThoughtByID(userID, thoughtID)
if err == nil && th.TaskListID != "" && !s.store.CanEditTaskList(userID, th.TaskListID) { if err == nil && th.TaskListID != "" && !s.store.CanEditTaskList(userID, th.TaskListID) {
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error()) return storage.ErrTaskListReadOnly
return
} }
audience := s.taskAudience(th) audience := s.taskAudience(th)
if err := s.store.DeleteThought(userID, thoughtID); err != nil { if err := s.store.DeleteThought(userID, thoughtID); err != nil {
writeStoreError(w, http.StatusInternalServerError, err) return err
return
} }
if th != nil { if th != nil {
s.notifyTaskChange(userID, th, nil, true) s.notifyTaskChange(userID, th, nil, true)
} }
s.wsHub.BroadcastToUsers(audience, "TASK_DELETED", map[string]string{"id": thoughtID}) 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}) writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
} }
+112 -11
View File
@@ -128,7 +128,8 @@ func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
"serverInfo": map[string]string{"name": "MindSift", "version": mcpServerVersion}, "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 " + "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 " + "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) }, nil)
case "ping": case "ping":
writeRPC(w, req.ID, map[string]interface{}{}, nil) writeRPC(w, req.ID, map[string]interface{}{}, nil)
@@ -275,11 +276,31 @@ func toTaskView(t *domain.Thought) taskView {
} }
type taskListView struct { type taskListView struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Icon string `json:"icon,omitempty"` Icon string `json:"icon,omitempty"`
Permission string `json:"permission"` Permission string `json:"permission"`
OpenTasks int `json:"openTasks"` 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 { type listView struct {
@@ -309,7 +330,7 @@ func toListView(l domain.SharedList) listView {
var mcpTools = []mcpTool{ var mcpTools = []mcpTool{
{ {
Name: "list_task_lists", 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, Scope: storage.ScopeTasksRead,
Schema: schema(nil, map[string]interface{}{}), Schema: schema(nil, map[string]interface{}{}),
run: func(s *Server, userID string, _ json.RawMessage) (interface{}, error) { run: func(s *Server, userID string, _ json.RawMessage) (interface{}, error) {
@@ -319,11 +340,39 @@ var mcpTools = []mcpTool{
} }
out := make([]taskListView, 0, len(lists)) out := make([]taskListView, 0, len(lists))
for _, l := range 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 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", 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.", 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 return nil, err
} }
a.taskPatch.Assignees, a.taskPatch.Recurrence, a.taskPatch.Subtasks = nil, nil, nil 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) t, err := s.updateTask(userID, a.ID, a.taskPatch)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -482,13 +532,34 @@ var mcpTools = []mcpTool{
return nil, err return nil, err
} }
done := a.Completed == nil || *a.Completed 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 { if err != nil {
return nil, err return nil, err
} }
return toTaskView(t), nil 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", Name: "list_lists",
Description: "Shopping lists, checklists and other shared lists the user can see, with open/total item counts.", 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 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", Name: "get_list",
Description: "One list with all its items (id, content, category, quantity, isCompleted).", Description: "One list with all its items (id, content, category, quantity, isCompleted).",
@@ -545,7 +645,7 @@ var mcpTools = []mcpTool{
if strings.TrimSpace(a.Content) == "" { if strings.TrimSpace(a.Content) == "" {
return nil, errors.New("'content' darf nicht leer sein") 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 { if err := decodeArgs(args, &a); err != nil {
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
item.AgentMark = s.store.SetListItemAgentMark(a.ItemID, true)
s.wsHub.BroadcastListEvent(a.ListID, "ITEM_UPDATED", item) s.wsHub.BroadcastListEvent(a.ListID, "ITEM_UPDATED", item)
return item, nil return item, nil
}, },
+84 -1
View File
@@ -2,6 +2,7 @@ package api
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -88,7 +89,10 @@ func TestMCPToolsFollowScopes(t *testing.T) {
// Read-only task key: lists tasks, cannot complete them // Read-only task key: lists tasks, cannot complete them
readKey, _ := store.CreateAPIKey(claims.UserID, "Reader", []string{"tasks:read"}) 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") { 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) 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) 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"` Content string `json:"content"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Priority string `json:"priority"` // low | normal | high | urgent 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"` Status ThoughtStatus `json:"status"`
IsCompleted bool `json:"isCompleted"` IsCompleted bool `json:"isCompleted"`
ConvertedType string `json:"convertedType,omitempty"` // task | document | list_item | document_block ConvertedType string `json:"convertedType,omitempty"` // task | document | list_item | document_block
@@ -268,6 +268,7 @@ type Thought struct {
SortOrder int `json:"sortOrder"` SortOrder int `json:"sortOrder"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
TriagedAt string `json:"triagedAt,omitempty"` TriagedAt string `json:"triagedAt,omitempty"`
AgentMark string `json:"agentMark,omitempty"` // created | edited: last change via MCP, untouched by the user since
} }
type ThoughtReorderItem struct { type ThoughtReorderItem struct {
@@ -570,6 +571,7 @@ type ListItem struct {
SortOrder int `json:"sortOrder"` SortOrder int `json:"sortOrder"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
CompletedAt string `json:"completedAt,omitempty"` CompletedAt string `json:"completedAt,omitempty"`
AgentMark string `json:"agentMark,omitempty"` // created | edited: last change via MCP, untouched by the user since
} }
type SharedList struct { 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. // GetThoughtByIDUnchecked loads a task without access check, for server-internal notifications.
func (s *Store) GetThoughtByIDUnchecked(thoughtID string) (*domain.Thought, error) { func (s *Store) GetThoughtByIDUnchecked(thoughtID string) (*domain.Thought, error) {
return scanThought(s.db.QueryRow( 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)) 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) { func (s *Store) GetListItems(listID string) ([]domain.ListItem, error) {
rows, err := s.db.Query( 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, `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 != '' FROM list_items li LEFT JOIN users u ON u.id = li.created_by AND li.created_by != ''
WHERE li.list_id = ? WHERE li.list_id = ?
ORDER BY li.is_completed ASC, li.sort_order ASC, li.created_at DESC`, 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() { for rows.Next() {
var it domain.ListItem var it domain.ListItem
var doneInt int 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 it.IsCompleted = doneInt != 0
items = append(items, it) items = append(items, it)
} }
@@ -461,11 +461,11 @@ func (s *Store) MoveListItem(itemID, targetListID string) (*domain.ListItem, err
var dInt int var dInt int
err := s.db.QueryRow( 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, `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 != '' FROM list_items li LEFT JOIN users u ON u.id = li.created_by AND li.created_by != ''
WHERE li.id = ?`, WHERE li.id = ?`,
itemID, 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 { if err != nil {
return nil, err return nil, err
} }
+1
View File
@@ -32,6 +32,7 @@ var migrations = []migration{
{9, "Web-Push", (*Store).migratePush}, {9, "Web-Push", (*Store).migratePush},
{10, "Registrierung und Einladungslinks", (*Store).migrateRegistration}, {10, "Registrierung und Einladungslinks", (*Store).migrateRegistration},
{11, "E-Mail-Bestätigung", (*Store).migrateEmailVerification}, {11, "E-Mail-Bestätigung", (*Store).migrateEmailVerification},
{12, "Markierung für Einträge von KI-Agenten", (*Store).migrateAgentMark},
} }
func (s *Store) migrate() error { 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) { func (s *Store) getThoughtRaw(id string) (*domain.Thought, error) {
t, err := scanThought(s.db.QueryRow( 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)) FROM thoughts WHERE id = ?`, id))
if err != nil { if err != nil {
return nil, err return nil, err
+65 -9
View File
@@ -76,11 +76,14 @@ func (s *Store) CreateThoughtWithDetails(userID, content, priority, source, dueD
Notes: notes, Notes: notes,
CreatedAt: domain.NowISO(), CreatedAt: domain.NowISO(),
} }
if source == "agent" {
t.AgentMark = AgentMarkCreated
}
_, err := s.db.Exec( _, 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) `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, ?, '[]')`, 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.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 { if err != nil {
return nil, err return nil, err
@@ -99,7 +102,7 @@ func scanThought(rows interface {
&t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source, &t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source,
&statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID, &statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID,
&t.DueDate, &t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent, &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 { if err != nil {
return nil, err return nil, err
@@ -136,7 +139,7 @@ func (s *Store) accessibleTaskListIDsQuery() string {
func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) { func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) {
row := s.db.QueryRow( 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()+`))`, FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`,
thoughtID, userID, userID, userID, userID, thoughtID, userID, userID, userID, userID,
) )
@@ -157,14 +160,14 @@ func (s *Store) GetThoughts(userID, statusFilter string, limit int) ([]domain.Th
if statusFilter != "" && statusFilter != "all" { if statusFilter != "" && statusFilter != "all" {
rows, err = s.db.Query( 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 = ? 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 ?`, 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, userID, statusFilter, limit,
) )
} else { } else {
rows, err = s.db.Query( 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 = ? FROM thoughts WHERE user_id = ?
ORDER BY sort_order ASC, created_at DESC LIMIT ?`, ORDER BY sort_order ASC, created_at DESC LIMIT ?`,
userID, limit, userID, limit,
@@ -190,7 +193,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
var err error var err error
if taskListID != "" { if taskListID != "" {
rows, err = s.db.Query( 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 FROM thoughts
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`)) WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
AND task_list_id = ? AND task_list_id = ?
@@ -200,7 +203,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
) )
} else { } else {
rows, err = s.db.Query( 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 FROM thoughts
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`)) WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task')) AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
@@ -509,6 +512,59 @@ func (s *Store) DeleteThought(userID, thoughtID string) error {
return err 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) { func (s *Store) PurgeDiscardedThoughts(userID string) (int64, error) {
res, err := s.db.Exec(`DELETE FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID) res, err := s.db.Exec(`DELETE FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID)
if err != nil { if err != nil {