diff --git a/CHANGELOG.md b/CHANGELOG.md index bf03c89..caa41ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); until 1.0, minor versions may contain breaking changes. +## [Unreleased] + +### Added + +- **Reverse engineering outside craft:** *Settings → Agents & commands* can give any mode the + `binary_info` and `decompile` (Ghidra) tools; the clean-room rule stays a craft rule. +- **Raw firmware in `decompile`:** optional `processor` (Ghidra language id, e.g. + `ARM:LE:32:Cortex`) and `base_address` for images without a file format (.bin, converted .hex). +- **Skill "reverse-engineering":** how to identify, decompile and explore programs and firmware + (processor table for common chips, HEX conversion, notes in `analysis/`). + ## [v202611-00019] – 2026-11-11 ### Added diff --git a/server/resources/skills/reverse-engineering/SKILL.md b/server/resources/skills/reverse-engineering/SKILL.md new file mode 100644 index 0000000..57e6d87 --- /dev/null +++ b/server/resources/skills/reverse-engineering/SKILL.md @@ -0,0 +1,64 @@ +--- +name: reverse-engineering +description: Analyse programs and firmware – identify the format, inspect with binary_info, decompile with Ghidra (decompile tool, also raw firmware with processor/base address), explore the output and write down findings. For file formats, protocols, bugs and interoperability. +--- +# Reverse engineering of programs and firmware + +You analyse a binary to understand how it works – a file format, a protocol, an algorithm, +a bug, or how to talk to a device. Work from cheap to expensive and write down what you learn. + +Only analyse binaries the user owns or may analyse (their own builds, their devices, programs +for interoperability). Decompiled code is third-party material: never commit it and never paste +it into the user's source – describe behaviour in your own words. + +## 1. Identify +- `file ` – format, architecture, stripped or not. +- Executables and libraries (PE .exe/.dll, ELF, Mach-O): `binary_info` – sections, imports, + exports, strings. Imports and strings tell a lot (file names, error messages, protocol + keywords, library names) before any decompiling. +- Archives and update packages: unpack first (`unzip`, `7z x`), then identify the parts. +- Intel HEX / S-record firmware: convert to a raw image – + `objcopy -I ihex -O binary fw.hex fw.bin` (the lowest address in the HEX file is the base + address). An ELF from the firmware's build (e.g. `zephyr.elf`, `firmware.elf`) is far better + than a raw image: symbols and addresses are included – look for it first. +- Raw images: look for clues with `strings -n 6 fw.bin | head -200` and + `python3 -c` one-liners (vector table, magic numbers, embedded file systems). + +## 2. Raw firmware: processor and load address +`decompile` needs both for images without a file format: + +| Chip family | `processor` | typical `base_address` | +|---|---|---| +| ARM Cortex-M (STM32, nRF52, RP2040, SAMD) | `ARM:LE:32:Cortex` | STM32 `0x08000000`, nRF52 `0x00000000`, RP2040 `0x10000000` | +| ARM Cortex-A / ARMv7 | `ARM:LE:32:v7` | from the bootloader / datasheet | +| AArch64 | `AARCH64:LE:64:v8A` | | +| ESP32 (Xtensa) | `Xtensa:LE:32:default` | per segment (use the ELF if possible) | +| AVR (Arduino) | `AVR8:LE:16:atmega256` or `AVR8:LE:16:default` | `0x0` | +| MIPS (routers) | `MIPS:BE:32:default` or `MIPS:LE:32:default` | e.g. `0x80000000` | + +Cortex-M check: the first word of the image is the initial stack pointer (0x2000xxxx = RAM), +the second the reset vector – it points just behind the base address. If the decompiled output +is mostly garbage, the processor or base address is wrong: fix it and run again (results are +cached per file *and* processor/address). + +## 3. Decompile +- `decompile` with the path (plus `processor`/`base_address` for raw images). Large programs: + start with `max_functions` (e.g. 2000), widen when needed. +- Output in `.codecraft/decompiled//`: `functions.tsv` (address, name, size, file, + signature), `functions/*.c`, `strings.tsv`, `imports.tsv`, `exports.tsv`. + +## 4. Explore +- Start from what you know: a string, an import or an export. + `grep -l "SetCommState" .codecraft/decompiled//functions` finds the functions that use it. +- Follow the calls by reading the functions; note addresses (`FUN_00401a30`) as you go. +- Broad searches through thousands of functions: delegate them to an `explore` subagent with a + precise question and let it report file:line. +- File formats: compare the reading/writing code with a sample file (`python3` to dump the bytes) + until every field is explained. + +## 5. Write it down +Keep findings in `analysis/notes.md` (create it; it is not committed): structure of the +program, important functions with addresses, data structures, file formats and protocols with +field tables, open questions. Make sure `.gitignore` excludes `analysis/`, `original/` and +`.codecraft/decompiled/`. At the end, report what you found and how sure you are – mark guesses +as guesses. diff --git a/server/src/config.rs b/server/src/config.rs index 525a223..72eee26 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -398,6 +398,10 @@ pub struct AgentConfig { /// Review loop after each turn of this mode. #[serde(default, skip_serializing_if = "Option::is_none")] pub refine: Option, + /// Reverse engineering tools (binary_info, decompile with Ghidra) in this mode; + /// craft has them anyway (deep mode). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reverse_tools: Option, } /// Refinement loop: after the agent finishes, a reviewer checks the result diff --git a/server/src/prompts.rs b/server/src/prompts.rs index ce3559b..8911f73 100644 --- a/server/src/prompts.rs +++ b/server/src/prompts.rs @@ -32,6 +32,8 @@ pub struct Agent { pub max_parallel: Option, /// Review loop after each turn (None/0 rounds = off). pub refine: Option, + /// binary_info and decompile available (besides craft). + pub reverse: bool, /// Source file of Markdown agents. #[serde(skip_serializing_if = "Option::is_none")] pub path: Option, @@ -43,12 +45,12 @@ pub use codecraft_core::prompts::*; pub fn builtin_agents(clean_room: bool) -> Vec { let craft = craft_prompt(clean_room); vec![ - Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None }, - Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None }, - Agent { name: "chat".into(), description: "Just talk: answers, explanations, web search – no files or shell".into(), mode: "primary".into(), prompt: CHAT_PROMPT.into(), tools: Some(vec!["web_search".into(), "web_fetch".into()]), read_only: true, style: "chat".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None }, - Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None }, - Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None }, - Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None }, + Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None }, + Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None }, + Agent { name: "chat".into(), description: "Just talk: answers, explanations, web search – no files or shell".into(), mode: "primary".into(), prompt: CHAT_PROMPT.into(), tools: Some(vec!["web_search".into(), "web_fetch".into()]), read_only: true, style: "chat".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None }, + Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None }, + Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None }, + Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None }, ] } @@ -122,6 +124,7 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec { source: "file".into(), max_parallel: meta.get("max_parallel").and_then(|v| v.parse().ok()), refine: meta.get("refine_rounds").and_then(|v| v.parse().ok()).map(|rounds| crate::config::RefineConfig { rounds, model: meta.get("refine_model").cloned(), criteria: None, after_todos: meta.get("refine_after_todos").is_none_or(|v| v != "false") }), + reverse: meta.get("reverse_tools").is_some_and(|v| v == "true"), path: Some(p.display().to_string()), }); } @@ -141,6 +144,7 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec { source: "config".into(), max_parallel: a.max_parallel.or(base.as_ref().and_then(|b| b.max_parallel)), refine: a.refine.clone().or(base.as_ref().and_then(|b| b.refine.clone())), + reverse: a.reverse_tools.unwrap_or_else(|| base.as_ref().is_some_and(|b| b.reverse)), path: None, }); } @@ -173,6 +177,7 @@ pub fn agents_for(app: &App, root: &Path, cwd: &Path, user_id: Option<&str>) -> source: "personal".into(), max_parallel: None, refine: a.refine, + reverse: a.reverse_tools.unwrap_or(false), path: None, }); } diff --git a/server/src/reverse.rs b/server/src/reverse.rs index 3ed69fa..e6accd3 100644 --- a/server/src/reverse.rs +++ b/server/src/reverse.rs @@ -142,7 +142,10 @@ impl Tool for Decompile { "Decompile an executable or library with Ghidra (x86/x64/ARM/MIPS/PowerPC/68k …, PE, ELF, \ Mach-O, raw DOS). Writes pseudo-C for every function plus strings, imports and exports \ into .codecraft/decompiled// and returns an overview. Can take several minutes for \ - large programs; results are cached per file." + large programs; results are cached per file. Raw firmware images (.bin without a file \ + format) need `processor` (Ghidra language id, e.g. \"ARM:LE:32:Cortex\" for Cortex-M, \ + \"ARM:LE:32:v8\", \"AVR8:LE:16:atmega256\", \"Xtensa:LE:32:default\" for ESP32) and usually \ + `base_address` (load address, e.g. \"0x08000000\" for STM32 flash)." .into() } fn schema(&self) -> Value { @@ -151,7 +154,9 @@ impl Tool for Decompile { "properties": { "path": {"type": "string"}, "max_functions": {"type": "integer", "description": "Limit for very large binaries (default: all)"}, - "force": {"type": "boolean", "description": "Re-run even if cached"} + "force": {"type": "boolean", "description": "Re-run even if cached"}, + "processor": {"type": "string", "description": "Ghidra language id for raw images, e.g. ARM:LE:32:Cortex (default: detected from the file format)"}, + "base_address": {"type": "string", "description": "Load address of a raw image, e.g. 0x08000000"} }, "required": ["path"] }) @@ -171,7 +176,20 @@ impl Tool for Decompile { bail!("{} is not a file", path.display()); } let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("binary").replace(|c: char| !c.is_ascii_alphanumeric() && c != '.' && c != '-', "_"); - let out_dir = ctx.cwd.join(".codecraft/decompiled").join(format!("{name}-{}", file_hash(&path)?)); + let processor = input["processor"].as_str().map(str::trim).filter(|s| !s.is_empty()).map(String::from); + let base = input["base_address"].as_str().map(str::trim).filter(|s| !s.is_empty()).map(String::from); + if processor.as_deref().is_some_and(|p| !p.chars().all(|c| c.is_ascii_alphanumeric() || ":_-.".contains(c))) { + bail!("invalid processor id (expected e.g. ARM:LE:32:Cortex)"); + } + if base.as_deref().is_some_and(|b| !b.trim_start_matches("0x").chars().all(|c| c.is_ascii_hexdigit())) { + bail!("invalid base_address (expected e.g. 0x08000000)"); + } + // Another processor or load address is another analysis. + let variant = match (&processor, &base) { + (None, None) => String::new(), + (p, b) => format!("-{}", crate::blobs::hash(format!("{p:?}{b:?}").as_bytes()).get(..8).unwrap_or("")), + }; + let out_dir = ctx.cwd.join(".codecraft/decompiled").join(format!("{name}-{}{variant}", file_hash(&path)?)); if out_dir.join(".done").exists() && !input["force"].as_bool().unwrap_or(false) { return Ok(ToolOutput::text(format!("(cached)\n{}", summarize(&out_dir, &ctx.cwd)?))); } @@ -183,7 +201,15 @@ impl Tool for Decompile { let scripts = scripts_dir().display().to_string(); let max = input["max_functions"].as_u64().map(|n| n.to_string()).unwrap_or_else(|| "2147483647".into()); let (p, proj, out) = (path.display().to_string(), project.display().to_string(), out_dir.display().to_string()); - let args = vec![proj.as_str(), "cc", "-import", p.as_str(), "-scriptPath", scripts.as_str(), "-postScript", "CodeCraftExport.java", out.as_str(), max.as_str(), "-deleteProject", "-max-cpu", "2"]; + let mut args = vec![proj.as_str(), "cc", "-import", p.as_str()]; + // Raw firmware: the binary loader with the given processor and load address. + if let Some(proc_id) = processor.as_deref() { + args.extend(["-processor", proc_id]); + } + if let Some(addr) = base.as_deref() { + args.extend(["-loader", "BinaryLoader", "-loader-baseAddr", addr]); + } + args.extend(["-scriptPath", scripts.as_str(), "-postScript", "CodeCraftExport.java", out.as_str(), max.as_str(), "-deleteProject", "-max-cpu", "2"]); let java = tools.join("jdk").display().to_string(); let mut cmd = match &ctx.sandbox { Some(spec) => { diff --git a/server/src/runner.rs b/server/src/runner.rs index bdedda0..8e72db7 100644 --- a/server/src/runner.rs +++ b/server/src/runner.rs @@ -226,7 +226,12 @@ pub fn available_tools(app: &App, agent: &Agent, owner: Option<&crate::store::Us all.into_iter() .filter(|t| !agent.read_only || t.read_only()) // Craft tools only where they are useful. - .filter(|t| agent.name == "craft" || !matches!(t.name(), "browser_snapshot" | "image_compare" | "binary_info" | "decompile" | "craft_phase") || agent.tools.is_some()) + .filter(|t| { + agent.name == "craft" + || agent.tools.is_some() + || (agent.reverse && matches!(t.name(), "binary_info" | "decompile")) + || !matches!(t.name(), "browser_snapshot" | "image_compare" | "binary_info" | "decompile" | "craft_phase") + }) .filter(|t| match &agent.tools { None => true, Some(list) => { @@ -456,7 +461,8 @@ pub async fn run_session(app: Arc, session_id: &str, cancel: CancellationTo agent: agent.name.clone(), roots: roots.clone(), sandbox: crate::sandbox::spec_for(&app, session.owner_id.as_deref()), - clean_room, + // Clean room is a craft rule; other modes with reverse tools may analyse binaries. + clean_room: clean_room && agent.name == "craft", owner_id: None, }; let by_name = |n: &str| tools.iter().find(|t| t.name() == n).cloned(); diff --git a/web/src/components/settings/AgentsEditor.tsx b/web/src/components/settings/AgentsEditor.tsx index bdd1fc1..7858b23 100644 --- a/web/src/components/settings/AgentsEditor.tsx +++ b/web/src/components/settings/AgentsEditor.tsx @@ -62,6 +62,7 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T refine_model: raw?.refine?.model ?? "", refine_criteria: raw?.refine?.criteria ?? "", refine_after_todos: (raw?.refine?.after_todos ?? a.refine?.after_todos ?? true) as boolean, + reverse_tools: !!(raw?.reverse_tools ?? a.reverse), }); const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "chat", "general", "explore"].includes(a.name)); @@ -78,6 +79,7 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T value: rounds || form.refine_model || form.refine_criteria ? { rounds, model: form.refine_model || null, criteria: form.refine_criteria || null, after_todos: form.refine_after_todos } : null, }); } + if (a.name !== "craft") patches.push({ path: [...base, "reverse_tools"], value: form.reverse_tools || null }); if (!builtin || form.description) patches.push({ path: [...base, "description"], value: form.description || null }); if (!builtin) { patches.push({ path: [...base, "mode"], value: form.mode }); @@ -166,6 +168,15 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T
{t("agents.refineAfterTodosHint")}
)} + {a.name !== "craft" && ( + <> + +
{t("agents.reverseToolsHint")}
+ + )} setForm({ ...form, description: e.target.value })} /> {a.mode !== "primary" &&
{t("agents.descriptionHint")}
} diff --git a/web/src/i18n/de.ts b/web/src/i18n/de.ts index 89f582c..3b38f07 100644 --- a/web/src/i18n/de.ts +++ b/web/src/i18n/de.ts @@ -189,7 +189,7 @@ export const de: Messages = { "tools.install": "Ghidra installieren", "tools.reinstall": "Installation prüfen / reparieren", "tools.installing": "Installiere (Download ca. 800 MB, auf ARM wird der Dekompiler kompiliert – das dauert einige Minuten)…", - "tools.ghidraHint": "Mit Ghidra können Agenten im Craft-Deep-Modus Programme dekompilieren (Windows .exe/.dll, Linux, DOS …), um zu verstehen, wie sie funktionieren. Wird samt eigenem JDK ins Daten-Volume installiert.", + "tools.ghidraHint": "Mit Ghidra können Agenten Programme und Firmware dekompilieren (Windows .exe/.dll, Linux, DOS …), um zu verstehen, wie sie funktionieren – im Craft-Deep-Modus und in Modi mit Reverse-Engineering-Werkzeugen (Einstellungen → Agenten & Befehle). Wird samt eigenem JDK ins Daten-Volume installiert.", "rewind.intro": "Die Unterhaltung wird auf den Stand vor dieser Nachricht zurückgesetzt; du kannst sie bearbeiten und neu senden.", "rewind.noFiles": "Der Agent hat seitdem keine Dateien geändert.", "rewind.restore": "Auch die {n} vom Agenten geänderte(n) Datei(en) zurücksetzen", @@ -336,6 +336,8 @@ export const de: Messages = { "activity.reviewing": "Ergebnis wird geprüft (Runde {round})…", "chat.reviewPassed": "Review bestanden:", "chat.todoReminder": "Weiterarbeiten – {n} offene Todos:", + "agents.reverseTools": "Reverse-Engineering-Werkzeuge (binary_info, decompile mit Ghidra)", + "agents.reverseToolsHint": "Der Agent kann in diesem Modus Programme und Firmware untersuchen und dekompilieren (Ghidra muss unter Einstellungen → Werkzeuge installiert sein). Craft hat sie im Deep-Modus ohnehin.", "agents.refineAfterTodos": "Jedes erledigte Todo prüfen und bis zum Ende der Liste arbeiten", "agents.refineAfterTodosHint": "Jedes Todo, das der Agent als erledigt markiert, wird geprüft; bei Befunden wird es wieder geöffnet. Beendet er den Turn mit offenen Todos, arbeitet er weiter (für unbeaufsichtigte Läufe, z. B. über Nacht). Aus: nur ein Review am Ende des Turns.", "chat.workSteps": "{n} Schritte", diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 9edcda3..aeae526 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -188,7 +188,7 @@ export const en = { "tools.install": "Install Ghidra", "tools.reinstall": "Check / repair installation", "tools.installing": "Installing (download ~800 MB, on ARM the decompiler is compiled – this takes several minutes)…", - "tools.ghidraHint": "Ghidra lets agents in craft Deep mode decompile programs (Windows .exe/.dll, Linux, DOS, …) to study how they work. Installed into the data volume together with its own JDK.", + "tools.ghidraHint": "Ghidra lets agents decompile programs and firmware (Windows .exe/.dll, Linux, DOS, …) to study how they work – in craft Deep mode and in modes with reverse engineering tools (Settings → Agents & commands). Installed into the data volume together with its own JDK.", "rewind.intro": "The conversation is reset to before this message; you can edit and resend it.", "rewind.noFiles": "The agent did not change files since then.", "rewind.restore": "Also restore the {n} file(s) the agent changed", @@ -335,6 +335,8 @@ export const en = { "activity.reviewing": "Reviewing the result (round {round})…", "chat.reviewPassed": "Review passed:", "chat.todoReminder": "Back to work – {n} open todos:", + "agents.reverseTools": "Reverse engineering tools (binary_info, decompile with Ghidra)", + "agents.reverseToolsHint": "The agent can inspect and decompile programs and firmware in this mode (Ghidra must be installed under Settings → Tools). Craft has them anyway in deep mode.", "agents.refineAfterTodos": "Review every completed todo and work until the list is done", "agents.refineAfterTodosHint": "Each todo the agent marks completed is reviewed; with findings it is reopened. Ending the turn with open todos sends the agent back to work (for unattended runs, e.g. overnight). Off: only one review at the end of the turn.", "chat.workSteps": "{n} steps", diff --git a/web/src/types.ts b/web/src/types.ts index 38e3735..40ab47d 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -168,6 +168,8 @@ export interface AgentInfo { source: "builtin" | "config" | "file" | "personal"; max_parallel?: number | null; refine?: { rounds: number; model?: string | null; criteria?: string | null; after_todos?: boolean } | null; + /** binary_info and decompile (Ghidra) available in this mode. */ + reverse?: boolean; /** Source file of Markdown agents. */ path?: string | null; } diff --git a/web/src/whatsnew.ts b/web/src/whatsnew.ts index 32a5b8a..deadf93 100644 --- a/web/src/whatsnew.ts +++ b/web/src/whatsnew.ts @@ -3,6 +3,10 @@ * under "unreleased"; scripts/release.sh renames the key to the release version. */ export const WHATS_NEW: Record = { + unreleased: { + en: ["Reverse engineering: decompile with Ghidra can be enabled for any mode (Settings → Agents & commands), now also for raw firmware"], + de: ["Reverse Engineering: Dekompilieren mit Ghidra lässt sich für jeden Modus einschalten (Einstellungen → Agenten & Befehle), jetzt auch für rohe Firmware"], + }, "202611-00019": { en: ["Long chats open and react much faster: older messages load when you scroll up, finished turns fold into one line", "Search in a chat (magnifier in the top bar) and ⋯ → Media: all images, videos and audio of a chat"], de: ["Lange Chats öffnen und reagieren viel schneller: ältere Nachrichten laden beim Hochscrollen, abgeschlossene Turns sind eingeklappt", "Suche im Chat (Lupe oben) und ⋯ → Medien: alle Bilder, Videos und Audiodateien eines Chats"],