994d5070f0
- Archiv-Home pro Projekt (archiveHome in ai-control.json); archive_panel mit Ordner, Beschreibung und Schlagwörtern (Frontmatter + Zeitstempel-Stem) - Archiv-Index (archive_index.rs): Scan mit Frontmatter, Wikilinks, Backlinks; Wiki-Seiten als strukturierte Daten (Übersicht/Tag-Seiten) - FTS5-Suche (archive_search.rs, rusqlite bundled): in-memory-Index pro Anfrage; MCP search_archive und Panel-Suchfeld (search_run) teilen die Engine - Panel: vier Puffer/Ansichten (Dokument, Befehle, Suche, Wiki) mit Tabs im Fenster-Header; gemeinsame Verdrahtung panel-wiring.ts, Kachel-CSS, Wiki-View mit Tag-Chips, Ordner-Sektionen und Backlinks - Archivieren speichert offene Bearbeitung (flush); Tab-Wechsel speichert statt zu blockieren; Fehler sichtbar als Panel-Toast - Abgelöstes Fenster: rahmenlos (Linux), eigene Kopfleiste mit Tabs und Fensterknöpfen, Theme-Kopplung über gemeinsames themes.ts - MCP-Tools show_commands, show_archive; Befehls-History mit Kachel-Löschen
415 lines
15 KiB
Rust
415 lines
15 KiB
Rust
//! Minimaler MCP-stdio-Server (`app --mcp-panel`), den claude als Tool-Server
|
|
//! startet. Er stellt genau ein Tool bereit: `write_panel(text)` schreibt den
|
|
//! Text in die Panel-Datei aus `AI_CONTROL_PANEL` (dieselbe Env, die der
|
|
//! Terminal-Prozess der PTY mitgibt und die claude an seine MCP-Kinder vererbt).
|
|
//! Der bestehende Datei-Watcher im Terminal-Prozess zieht den Inhalt ins Panel.
|
|
//!
|
|
//! Protokoll: JSON-RPC 2.0, newline-getrennt (ein Objekt pro Zeile).
|
|
|
|
use serde_json::{json, Value};
|
|
use std::io::{BufRead, Write};
|
|
|
|
pub fn run_mcp_panel() {
|
|
let stdin = std::io::stdin();
|
|
let mut stdout = std::io::stdout();
|
|
let mut reader = stdin.lock();
|
|
let mut line = String::new();
|
|
|
|
loop {
|
|
line.clear();
|
|
if reader.read_line(&mut line).unwrap_or(0) == 0 {
|
|
break; // stdin geschlossen -> claude hat den Server beendet
|
|
}
|
|
let trimmed = line.trim();
|
|
if trimmed.is_empty() {
|
|
continue;
|
|
}
|
|
let Ok(req) = serde_json::from_str::<Value>(trimmed) else {
|
|
continue;
|
|
};
|
|
|
|
let id = req.get("id").cloned();
|
|
let method = req.get("method").and_then(Value::as_str).unwrap_or("");
|
|
let result = handle(method, &req);
|
|
|
|
// Nur Requests (mit id) bekommen eine Antwort; Notifications nicht.
|
|
let (Some(id), Some(result)) = (id, result) else {
|
|
continue;
|
|
};
|
|
let resp = json!({ "jsonrpc": "2.0", "id": id, "result": result });
|
|
if writeln!(stdout, "{resp}").is_err() || stdout.flush().is_err() {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Liefert das `result` für einen Request, oder None für Notifications/Unbekanntes.
|
|
fn handle(method: &str, req: &Value) -> Option<Value> {
|
|
match method {
|
|
"initialize" => {
|
|
// Vom Client vorgeschlagene Protokollversion übernehmen.
|
|
let protocol = req["params"]["protocolVersion"]
|
|
.as_str()
|
|
.unwrap_or("2024-11-05");
|
|
Some(json!({
|
|
"protocolVersion": protocol,
|
|
"capabilities": { "tools": {} },
|
|
"serverInfo": { "name": "text-panel", "version": env!("CARGO_PKG_VERSION") },
|
|
}))
|
|
}
|
|
"tools/list" => Some(json!({
|
|
"tools": [
|
|
{
|
|
"name": "write_panel",
|
|
"description":
|
|
"Legt einen längeren Entwurf (ADR, E-Mail, Dokument, Spezifikation, \
|
|
Commit-Message, Textbaustein) im ai-control-Panel neben dem Terminal \
|
|
ab, wo er mit der Maus selektierbar und über einen Button kopierbar \
|
|
ist. Statt den Text zusätzlich als Fließtext auszugeben, dieses Tool \
|
|
aufrufen und im Chat nur kurz bestätigen. Für eine bestehende Datei \
|
|
IMMER `path` statt `text` übergeben — der Server liest die Datei \
|
|
selbst von der Platte, ohne dass ihr Inhalt generiert werden muss.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"text": {
|
|
"type": "string",
|
|
"description": "Vollständiger Entwurf als Markdown-Rohtext.",
|
|
},
|
|
"path": {
|
|
"type": "string",
|
|
"description":
|
|
"Statt `text`: Pfad einer vorhandenen Datei, deren Inhalt ins \
|
|
Panel geladen wird. Genau eines von beiden angeben.",
|
|
}
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"name": "write_commands",
|
|
"description":
|
|
"Listet Shell-Befehle, die der Nutzer ausführen soll, als kopierbare \
|
|
Kacheln im ai-control-Panel. IMMER nutzen, wenn ein Befehl für den \
|
|
Nutzer bestimmt ist — statt ihn als Codeblock in den Chat zu \
|
|
schreiben; im Chat nur kurz einordnen. Die Befehle werden an die \
|
|
Befehls-History der Session angehängt (flüchtig, startet mit \
|
|
jeder Session leer).",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"commands": {
|
|
"type": "array",
|
|
"description": "Befehle in Ausführungsreihenfolge.",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"cmd": {
|
|
"type": "string",
|
|
"description": "Der Befehl, exakt so ausführbar.",
|
|
},
|
|
"note": {
|
|
"type": "string",
|
|
"description": "Optionale Kurznotiz, was der Befehl tut.",
|
|
}
|
|
},
|
|
"required": ["cmd"],
|
|
},
|
|
}
|
|
},
|
|
"required": ["commands"],
|
|
},
|
|
},
|
|
{
|
|
"name": "show_commands",
|
|
"description":
|
|
"Zeigt die Befehls-History im ai-control-Panel (Kachel-Ansicht mit \
|
|
allen bisher ausgegebenen Befehlen), ohne etwas anzuhängen. Nutzen, \
|
|
wenn der Nutzer die Befehlsliste sehen will („zeig die Befehle“, \
|
|
„zeige die Befehlsliste“).",
|
|
"inputSchema": { "type": "object", "properties": {} },
|
|
},
|
|
{
|
|
"name": "archive_panel",
|
|
"description":
|
|
"Archiviert den aktuell im Panel liegenden Entwurf dauerhaft als \
|
|
Markdown-Datei im Archiv-Home des Projekts. Auf Wunsch nutzen \
|
|
(Nutzer sagt etwa „archiviere das“). Beim Archivieren `folder`, \
|
|
`description` und `tags` mitgeben — einmalige Kuratierung im Moment \
|
|
des Archivierens, landet im Frontmatter. Ist kein Archiv-Home \
|
|
konfiguriert, den Zielpfad im Argument `dir` mitgeben (der Nutzer \
|
|
nennt ihn); ohne `dir` und ohne konfiguriertes Home meldet das Tool \
|
|
das zurück.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"dir": {
|
|
"type": "string",
|
|
"description":
|
|
"Optionales Archiv-Home (absoluter Pfad). Wird gesetzt und für \
|
|
künftige Archivierungen gemerkt.",
|
|
},
|
|
"folder": {
|
|
"type": "string",
|
|
"description":
|
|
"Optionaler Unterordner im Archiv-Home, relativ (z. B. \
|
|
`konzepte/panel`). Wird angelegt.",
|
|
},
|
|
"description": {
|
|
"type": "string",
|
|
"description": "Einzeiler zum Inhalt fürs Frontmatter.",
|
|
},
|
|
"tags": {
|
|
"type": "array",
|
|
"items": { "type": "string" },
|
|
"description": "Schlagwörter fürs Frontmatter (kurze Slugs).",
|
|
}
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"name": "show_archive",
|
|
"description":
|
|
"Zeigt die Archiv-Übersicht des Projekts als Wiki-Seite im Panel: \
|
|
Dokumente nach Ordnern gruppiert, mit Beschreibungen und \
|
|
klickbaren Schlagwort-Links. Mit `tag` stattdessen die Seite eines \
|
|
Schlagworts. Nutzen, wenn der Nutzer das Archiv sehen will \
|
|
(„zeig das Archiv“).",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"tag": {
|
|
"type": "string",
|
|
"description": "Optional: Seite dieses Schlagworts statt der Übersicht.",
|
|
}
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"name": "search_archive",
|
|
"description":
|
|
"Volltext-Suche über das Panel-Archiv des Projekts (FTS5-Syntax: \
|
|
Wörter, \"Phrasen\", Präfix*). Die Treffer erscheinen als Kacheln \
|
|
im Panel; das Tool liefert sie zusätzlich mit Pfad und Snippet \
|
|
zurück. Nutzen, wenn der Nutzer im Archiv suchen will („such im \
|
|
Archiv nach …“).",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {
|
|
"type": "string",
|
|
"description": "Suchanfrage (FTS5-Syntax).",
|
|
},
|
|
"tag": {
|
|
"type": "string",
|
|
"description": "Optional: auf ein Schlagwort einengen.",
|
|
}
|
|
},
|
|
},
|
|
},
|
|
],
|
|
})),
|
|
"tools/call" => Some(call_tool(req)),
|
|
"ping" => Some(json!({})),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn call_tool(req: &Value) -> Value {
|
|
match req["params"]["name"].as_str().unwrap_or("") {
|
|
"write_panel" => call_write(req),
|
|
"write_commands" => call_write_commands(req),
|
|
"show_commands" => call_show_commands(),
|
|
"archive_panel" => call_archive(req),
|
|
"search_archive" => call_search(req),
|
|
"show_archive" => call_show_archive(req),
|
|
other => err(format!("Unbekanntes Tool: {other}")),
|
|
}
|
|
}
|
|
|
|
/// Erfolgs-Antwort eines Tool-Aufrufs (ein Text-Content).
|
|
fn ok(text: String) -> Value {
|
|
json!({ "content": [{ "type": "text", "text": text }] })
|
|
}
|
|
|
|
/// Fehler-Antwort eines Tool-Aufrufs.
|
|
fn err(text: String) -> Value {
|
|
json!({ "content": [{ "type": "text", "text": text }], "isError": true })
|
|
}
|
|
|
|
/// Pfad aus der PTY-Umgebung; fehlt die Variable, läuft das Terminal
|
|
/// außerhalb von ai-control.
|
|
fn env_path(var: &str) -> Result<String, Value> {
|
|
std::env::var(var)
|
|
.map_err(|_| err(format!("{var} nicht gesetzt (Terminal außerhalb ai-control).")))
|
|
}
|
|
|
|
/// Schreibt Text in die Panel-Datei aus AI_CONTROL_PANEL.
|
|
fn write_panel_text(text: &str) -> Result<(), Value> {
|
|
let path = env_path("AI_CONTROL_PANEL")?;
|
|
std::fs::write(&path, text).map_err(|e| err(format!("Panel-Datei nicht schreibbar: {e}")))
|
|
}
|
|
|
|
fn call_write(req: &Value) -> Value {
|
|
let args = &req["params"]["arguments"];
|
|
// `path` lädt eine vorhandene Datei serverseitig — der schnelle Weg, ohne
|
|
// dass das Modell den Inhalt Token für Token als `text` generieren muss.
|
|
let (text, ok_msg) = match args["path"].as_str() {
|
|
Some(src) => match std::fs::read_to_string(src) {
|
|
Ok(content) => (content, "Datei ins Panel geladen."),
|
|
Err(e) => return err(format!("Datei {src} nicht lesbar: {e}")),
|
|
},
|
|
None => (
|
|
args["text"].as_str().unwrap_or("").to_string(),
|
|
"Entwurf ins Panel geschrieben.",
|
|
),
|
|
};
|
|
match write_panel_text(&text) {
|
|
Ok(()) => ok(ok_msg.into()),
|
|
Err(e) => e,
|
|
}
|
|
}
|
|
|
|
/// Merkt, ob dieser Server-Prozess (= eine claude-Session) schon einen
|
|
/// Session-Marker in die History geschrieben hat.
|
|
static SESSION_MARKED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
|
|
|
fn call_write_commands(req: &Value) -> Value {
|
|
let commands = req["params"]["arguments"]["commands"].clone();
|
|
let count = commands.as_array().map(Vec::len).unwrap_or(0);
|
|
let path = match env_path("AI_CONTROL_COMMANDS") {
|
|
Ok(path) => path,
|
|
Err(e) => return e,
|
|
};
|
|
let ts = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0);
|
|
// Ein Record pro Aufruf; der erste Aufruf der Session bekommt einen
|
|
// Marker-Record davor (Session-Trenner in der Kachel-Ansicht).
|
|
let mut out = String::new();
|
|
if !SESSION_MARKED.swap(true, std::sync::atomic::Ordering::SeqCst) {
|
|
out.push_str(&json!({ "ts": ts, "session": true }).to_string());
|
|
out.push('\n');
|
|
}
|
|
out.push_str(&json!({ "ts": ts, "commands": commands }).to_string());
|
|
out.push('\n');
|
|
let res = std::fs::OpenOptions::new()
|
|
.create(true)
|
|
.append(true)
|
|
.open(&path)
|
|
.and_then(|mut f| std::io::Write::write_all(&mut f, out.as_bytes()));
|
|
match res {
|
|
Ok(()) => ok(format!("{count} Befehl(e) als Kacheln im Panel abgelegt.")),
|
|
Err(e) => err(format!("Command-History nicht schreibbar: {e}")),
|
|
}
|
|
}
|
|
|
|
/// Öffnet die Kachel-Ansicht, ohne der History etwas hinzuzufügen: mtime der
|
|
/// History-Datei anfassen genügt — der Watcher im Terminal-Prozess meldet sie
|
|
/// als `commands-update`, das Panel schaltet auf „Befehle“ um.
|
|
fn call_show_commands() -> Value {
|
|
let path = match env_path("AI_CONTROL_COMMANDS") {
|
|
Ok(path) => path,
|
|
Err(e) => return e,
|
|
};
|
|
let res = std::fs::OpenOptions::new()
|
|
.create(true)
|
|
.append(true)
|
|
.open(&path)
|
|
.and_then(|f| f.set_modified(std::time::SystemTime::now()));
|
|
match res {
|
|
Ok(()) => ok("Befehls-History im Panel geöffnet.".into()),
|
|
Err(e) => err(format!("Command-History nicht erreichbar: {e}")),
|
|
}
|
|
}
|
|
|
|
fn call_archive(req: &Value) -> Value {
|
|
let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default();
|
|
let args = &req["params"]["arguments"];
|
|
let dir = args["dir"].as_str();
|
|
let meta = crate::domain::archive::ArchiveMeta {
|
|
folder: args["folder"].as_str().map(str::to_string),
|
|
description: args["description"].as_str().map(str::to_string),
|
|
tags: args["tags"]
|
|
.as_array()
|
|
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
|
|
.unwrap_or_default(),
|
|
};
|
|
match crate::domain::archive::archive_panel_content(&project, dir, &meta) {
|
|
Ok(path) => ok(format!("Archiviert: {}", path.display())),
|
|
Err(e) => err(format!(
|
|
"Nicht archiviert: {e}. Zielordner im Argument `dir` angeben oder im Panel wählen."
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Archiv-Übersicht bzw. Schlagwort-Seite generieren und in den Wiki-Puffer
|
|
/// schreiben — der Watcher zieht sie als Wiki-Ansicht ins Panel.
|
|
fn call_show_archive(req: &Value) -> Value {
|
|
let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default();
|
|
let home = match crate::domain::archive::require_archive_home(&project) {
|
|
Ok(home) => home,
|
|
Err(e) => return err(e),
|
|
};
|
|
let tag = req["params"]["arguments"]["tag"].as_str();
|
|
let page = match crate::domain::archive_index::archive_page(&home, tag) {
|
|
Ok(page) => page,
|
|
Err(e) => return err(e),
|
|
};
|
|
let path = match env_path("AI_CONTROL_WIKI") {
|
|
Ok(path) => path,
|
|
Err(e) => return e,
|
|
};
|
|
let json = match serde_json::to_string(&page) {
|
|
Ok(json) => json,
|
|
Err(e) => return err(e.to_string()),
|
|
};
|
|
match std::fs::write(&path, json) {
|
|
Ok(()) => ok(match tag {
|
|
Some(t) => format!("Schlagwort-Seite #{t} im Panel."),
|
|
None => "Archiv-Übersicht im Panel.".to_string(),
|
|
}),
|
|
Err(e) => err(format!("Wiki-Datei nicht schreibbar: {e}")),
|
|
}
|
|
}
|
|
|
|
/// Volltext-Suche übers Archiv: Treffer als JSON in die Suchtreffer-Datei
|
|
/// (der Watcher zieht sie als Kacheln ins Panel) und als Text zurück an claude.
|
|
fn call_search(req: &Value) -> Value {
|
|
let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default();
|
|
let home = match crate::domain::archive::require_archive_home(&project) {
|
|
Ok(home) => home,
|
|
Err(e) => return err(e),
|
|
};
|
|
let args = &req["params"]["arguments"];
|
|
let query = args["query"].as_str().unwrap_or("");
|
|
let tag = args["tag"].as_str();
|
|
let hits = match crate::domain::archive_search::search(&home, query, tag, 20) {
|
|
Ok(hits) => hits,
|
|
Err(e) => return err(format!("Suche fehlgeschlagen: {e}")),
|
|
};
|
|
let search_path = match env_path("AI_CONTROL_SEARCH") {
|
|
Ok(path) => path,
|
|
Err(e) => return e,
|
|
};
|
|
let payload = json!({
|
|
"query": query,
|
|
"tag": tag,
|
|
"home": home.display().to_string(),
|
|
"hits": hits,
|
|
});
|
|
if let Err(e) = std::fs::write(&search_path, payload.to_string()) {
|
|
return err(format!("Suchtreffer-Datei nicht schreibbar: {e}"));
|
|
}
|
|
let list: Vec<String> = hits
|
|
.iter()
|
|
.map(|h| format!("- {} — {} ({})", h.relpath, h.title, h.snippet))
|
|
.collect();
|
|
ok(format!(
|
|
"{} Treffer, als Kacheln im Panel. Archiv: {}\n{}",
|
|
hits.len(),
|
|
home.display(),
|
|
list.join("\n")
|
|
))
|
|
}
|