nexo-brain 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/bin/nexo-brain.js +36 -19
- package/package.json +1 -1
- package/src/plugins/agents.py +6 -6
- package/src/plugins/backup.py +1 -1
- package/src/plugins/episodic_memory.py +2 -2
- package/src/scripts/nexo-sleep.py +11 -11
- package/src/tools_learnings.py +3 -3
- package/src/tools_reminders_crud.py +26 -26
package/README.md
CHANGED
|
@@ -332,7 +332,7 @@ That's it. No need to run `claude` manually. Atlas will greet you immediately
|
|
|
332
332
|
| Component | What | Where |
|
|
333
333
|
|-----------|------|-------|
|
|
334
334
|
| Cognitive engine | Python: fastembed, numpy, vector search | pip packages |
|
|
335
|
-
| MCP server |
|
|
335
|
+
| MCP server | 109+ tools for memory, cognition, learning, guard | ~/.nexo/ |
|
|
336
336
|
| Plugins | Guard, episodic memory, cognitive memory, entities, preferences | ~/.nexo/plugins/ |
|
|
337
337
|
| Hooks (5) | SessionStart briefing, Stop post-mortem, PostToolUse capture, PreCompact checkpoint, Caffeinate | ~/.nexo/hooks/ |
|
|
338
338
|
| Reflection engine | Processes session buffer, extracts patterns, updates user model | ~/.nexo/scripts/ |
|
|
@@ -345,7 +345,7 @@ That's it. No need to run `claude` manually. Atlas will greet you immediately
|
|
|
345
345
|
|
|
346
346
|
- **macOS, Linux, or Windows**
|
|
347
347
|
- **Node.js 18+** (for the installer)
|
|
348
|
-
- **Claude Opus (latest version) strongly recommended.** NEXO Brain provides 109+ MCP tools across
|
|
348
|
+
- **Claude Opus (latest version) strongly recommended.** NEXO Brain provides 109+ MCP tools across 19 categories. This cognitive load requires a top-tier model with large context window. Smaller models (Haiku, Sonnet) may struggle with tool selection and produce inconsistent results. Opus handles all 109+ tools without hesitation.
|
|
349
349
|
- Python 3, Homebrew, and Claude Code are installed automatically if missing.
|
|
350
350
|
|
|
351
351
|
## Architecture
|
|
@@ -372,7 +372,7 @@ That's it. No need to run `claude` manually. Atlas will greet you immediately
|
|
|
372
372
|
| Agents | 5 | get, create, update, delete, list | Agent delegation registry |
|
|
373
373
|
| Backup | 3 | now, list, restore | SQLite data safety |
|
|
374
374
|
| Evolution | 5 | propose, approve, reject, status, history | Self-improvement proposals |
|
|
375
|
-
| Adaptive & Somatic
|
|
375
|
+
| Adaptive & Somatic | 4 | adaptive_weights, adaptive_override, somatic_check, somatic_stats | Learned signal weights + pain memory per file |
|
|
376
376
|
| Knowledge Graph | 4 | kg_query, kg_path, kg_neighbors, kg_stats | Bi-temporal entity-relationship graph |
|
|
377
377
|
|
|
378
378
|
### Plugin System
|
|
@@ -431,7 +431,7 @@ NEXO Brain is designed as an MCP server. Claude Code is the primary supported cl
|
|
|
431
431
|
npx nexo-brain
|
|
432
432
|
```
|
|
433
433
|
|
|
434
|
-
All
|
|
434
|
+
All 109+ tools are available immediately after installation. The installer configures Claude Code's `~/.claude/settings.json` automatically.
|
|
435
435
|
|
|
436
436
|
### OpenClaw
|
|
437
437
|
|
package/bin/nexo-brain.js
CHANGED
|
@@ -211,27 +211,37 @@ async function main() {
|
|
|
211
211
|
}
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
-
// Find or install
|
|
215
|
-
let hasBrew = run("which brew");
|
|
216
|
-
if (!hasBrew) {
|
|
217
|
-
log("Homebrew not found. Installing...");
|
|
218
|
-
spawnSync("/bin/bash", ["-c", '$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)'], {
|
|
219
|
-
stdio: "inherit",
|
|
220
|
-
});
|
|
221
|
-
hasBrew = run("which brew") || run("eval $(/opt/homebrew/bin/brew shellenv) && which brew");
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// Find or install Python
|
|
214
|
+
// Find or install Python (platform-aware)
|
|
225
215
|
let python = run("which python3");
|
|
226
216
|
if (!python) {
|
|
227
|
-
if (
|
|
228
|
-
|
|
229
|
-
|
|
217
|
+
if (platform === "darwin") {
|
|
218
|
+
// macOS: use Homebrew
|
|
219
|
+
let hasBrew = run("which brew");
|
|
220
|
+
if (!hasBrew) {
|
|
221
|
+
log("Homebrew not found. Installing...");
|
|
222
|
+
spawnSync("/bin/bash", ["-c", '$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)'], {
|
|
223
|
+
stdio: "inherit",
|
|
224
|
+
});
|
|
225
|
+
hasBrew = run("which brew") || run("eval $(/opt/homebrew/bin/brew shellenv) && which brew");
|
|
226
|
+
}
|
|
227
|
+
if (hasBrew) {
|
|
228
|
+
log("Python 3 not found. Installing via Homebrew...");
|
|
229
|
+
spawnSync("brew", ["install", "python3"], { stdio: "inherit" });
|
|
230
|
+
python = run("which python3");
|
|
231
|
+
}
|
|
232
|
+
} else if (platform === "linux") {
|
|
233
|
+
// Linux: try apt or yum
|
|
234
|
+
log("Python 3 not found. Attempting install...");
|
|
235
|
+
if (run("which apt-get")) {
|
|
236
|
+
spawnSync("sudo", ["apt-get", "install", "-y", "python3", "python3-pip", "python3-venv"], { stdio: "inherit" });
|
|
237
|
+
} else if (run("which yum")) {
|
|
238
|
+
spawnSync("sudo", ["yum", "install", "-y", "python3", "python3-pip"], { stdio: "inherit" });
|
|
239
|
+
}
|
|
230
240
|
python = run("which python3");
|
|
231
241
|
}
|
|
232
242
|
if (!python) {
|
|
233
243
|
log("Python 3 not found and couldn't install automatically.");
|
|
234
|
-
log("Install it
|
|
244
|
+
log(platform === "darwin" ? "Install it: brew install python3" : "Install it: sudo apt install python3");
|
|
235
245
|
process.exit(1);
|
|
236
246
|
}
|
|
237
247
|
}
|
|
@@ -242,13 +252,20 @@ async function main() {
|
|
|
242
252
|
let claudeInstalled = run("which claude");
|
|
243
253
|
if (!claudeInstalled) {
|
|
244
254
|
log("Claude Code not found. Installing...");
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
255
|
+
// Try npx first (no sudo needed), then npm -g as fallback
|
|
256
|
+
spawnSync("npx", ["-y", "@anthropic-ai/claude-code", "--version"], { stdio: "pipe", timeout: 60000 });
|
|
257
|
+
claudeInstalled = run("which claude") || run("npx -y @anthropic-ai/claude-code --version");
|
|
258
|
+
if (!claudeInstalled) {
|
|
259
|
+
// Fallback: npm -g (may need sudo on Linux)
|
|
260
|
+
const npmCmd = platform === "linux" ? "sudo" : "npm";
|
|
261
|
+
const npmArgs = platform === "linux" ? ["npm", "install", "-g", "@anthropic-ai/claude-code"] : ["install", "-g", "@anthropic-ai/claude-code"];
|
|
262
|
+
spawnSync(npmCmd, npmArgs, { stdio: "inherit" });
|
|
263
|
+
claudeInstalled = run("which claude");
|
|
264
|
+
}
|
|
249
265
|
if (!claudeInstalled) {
|
|
250
266
|
log("Could not install Claude Code automatically.");
|
|
251
267
|
log("Install it manually: npm install -g @anthropic-ai/claude-code");
|
|
268
|
+
log("(On Linux you may need: sudo npm install -g @anthropic-ai/claude-code)");
|
|
252
269
|
process.exit(1);
|
|
253
270
|
}
|
|
254
271
|
log("Claude Code installed successfully.");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
5
|
"description": "NEXO — Cognitive co-operator for Claude Code. Atkinson-Shiffrin memory, semantic RAG, trust scoring, and metacognitive error prevention.",
|
|
6
6
|
"bin": {
|
package/src/plugins/agents.py
CHANGED
|
@@ -4,7 +4,7 @@ from db import create_agent, get_agent, list_agents, update_agent, delete_agent
|
|
|
4
4
|
def handle_agent_get(id: str) -> str:
|
|
5
5
|
"""Get an agent's full profile by ID."""
|
|
6
6
|
a = get_agent(id)
|
|
7
|
-
if not a: return f"
|
|
7
|
+
if not a: return f"Agent '{id}' not found."
|
|
8
8
|
lines = [f"AGENTE: {a['name']} ({a['id']})", f" Especialización: {a['specialization']}", f" Modelo: {a['model']}"]
|
|
9
9
|
if a["tools"]: lines.append(f" Tools: {a['tools']}")
|
|
10
10
|
if a["context_files"]: lines.append(f" Contexto: {a['context_files']}")
|
|
@@ -15,7 +15,7 @@ def handle_agent_create(id: str, name: str, specialization: str, model: str = "s
|
|
|
15
15
|
tools: str = "", context_files: str = "", rules: str = "") -> str:
|
|
16
16
|
"""Register a new agent in the registry."""
|
|
17
17
|
create_agent(id, name, specialization, model, tools, context_files, rules)
|
|
18
|
-
return f"
|
|
18
|
+
return f"Agent '{id}' ({name}) registered. Model: {model}"
|
|
19
19
|
|
|
20
20
|
def handle_agent_update(id: str, name: str = "", specialization: str = "", model: str = "",
|
|
21
21
|
tools: str = "", context_files: str = "", rules: str = "") -> str:
|
|
@@ -26,12 +26,12 @@ def handle_agent_update(id: str, name: str = "", specialization: str = "", model
|
|
|
26
26
|
if v: kwargs[k] = v
|
|
27
27
|
if not kwargs: return "Nada que actualizar."
|
|
28
28
|
update_agent(id, **kwargs)
|
|
29
|
-
return f"
|
|
29
|
+
return f"Agent '{id}' updated."
|
|
30
30
|
|
|
31
31
|
def handle_agent_list() -> str:
|
|
32
32
|
"""List all registered agents."""
|
|
33
33
|
agents = list_agents()
|
|
34
|
-
if not agents: return "
|
|
34
|
+
if not agents: return "No agents registered."
|
|
35
35
|
lines = ["AGENTES REGISTRADOS:"]
|
|
36
36
|
for a in agents:
|
|
37
37
|
lines.append(f" {a['id']} — {a['name']} ({a['model']}) — {a['specialization'][:60]}")
|
|
@@ -40,8 +40,8 @@ def handle_agent_list() -> str:
|
|
|
40
40
|
def handle_agent_delete(id: str) -> str:
|
|
41
41
|
"""Remove an agent from the registry."""
|
|
42
42
|
if not delete_agent(id):
|
|
43
|
-
return f"ERROR:
|
|
44
|
-
return f"
|
|
43
|
+
return f"ERROR: Agent '{id}' not found."
|
|
44
|
+
return f"Agent '{id}' deleted."
|
|
45
45
|
|
|
46
46
|
TOOLS = [
|
|
47
47
|
(handle_agent_get, "nexo_agent_get", "Get an agent's full profile"),
|
package/src/plugins/backup.py
CHANGED
|
@@ -56,7 +56,7 @@ def handle_backup_restore(filename: str) -> str:
|
|
|
56
56
|
"""
|
|
57
57
|
src = os.path.join(BACKUP_DIR, filename)
|
|
58
58
|
if not os.path.isfile(src):
|
|
59
|
-
return f"Backup
|
|
59
|
+
return f"Backup not found: {filename}"
|
|
60
60
|
|
|
61
61
|
# Create safety backup first
|
|
62
62
|
safety = os.path.join(BACKUP_DIR, f"nexo-pre-restore-{time.strftime('%Y%m%d%H%M%S')}.db")
|
|
@@ -101,7 +101,7 @@ def handle_decision_search(query: str = '', domain: str = '', days: int = 30) ->
|
|
|
101
101
|
results = search_decisions(query, domain, days)
|
|
102
102
|
if not results:
|
|
103
103
|
scope = f"'{query}'" if query else domain or 'todas'
|
|
104
|
-
return f"Sin decisiones encontradas para {scope}
|
|
104
|
+
return f"Sin decisiones encontradas para {scope} in {days} days."
|
|
105
105
|
|
|
106
106
|
lines = [f"DECISIONES ({len(results)}):"]
|
|
107
107
|
for d in results:
|
|
@@ -312,7 +312,7 @@ def handle_change_search(query: str = '', files: str = '', days: int = 30) -> st
|
|
|
312
312
|
results = search_changes(query, files, days)
|
|
313
313
|
if not results:
|
|
314
314
|
scope = f"'{query}'" if query else files or 'todos'
|
|
315
|
-
return f"
|
|
315
|
+
return f"No changes found for {scope} in {days} days."
|
|
316
316
|
|
|
317
317
|
lines = [f"CAMBIOS ({len(results)}):"]
|
|
318
318
|
for c in results:
|
|
@@ -618,17 +618,17 @@ Reporta cuantos registros eliminaste.""")
|
|
|
618
618
|
|
|
619
619
|
tasks_str = "\n\n".join(tasks)
|
|
620
620
|
|
|
621
|
-
return f"""
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
-
|
|
626
|
-
-
|
|
627
|
-
-
|
|
628
|
-
-
|
|
629
|
-
-
|
|
630
|
-
-
|
|
631
|
-
-
|
|
621
|
+
return f"""You are NEXO Sleep System. Your job is to PRUNE memory.
|
|
622
|
+
You are NOT interactive. Do NOT wait for input. Execute the following tasks and exit.
|
|
623
|
+
|
|
624
|
+
ABSOLUTE RULES:
|
|
625
|
+
- NEVER delete credentials, tokens, account IDs, API endpoints, keys, secrets.
|
|
626
|
+
- NEVER delete operational rules marked as "CRITICAL" or "HIGHEST PRIORITY".
|
|
627
|
+
- NEVER delete information about infrastructure (servers, repos, deploys).
|
|
628
|
+
- You CAN merge redundant sections.
|
|
629
|
+
- You CAN remove obsolete technical information (fixed >30 days ago and never referenced since).
|
|
630
|
+
- You CAN compress long paragraphs into concise bullets.
|
|
631
|
+
- Every line you remove must have a clear reason. When in doubt, do NOT delete.
|
|
632
632
|
|
|
633
633
|
{tasks_str}
|
|
634
634
|
|
package/src/tools_learnings.py
CHANGED
|
@@ -24,7 +24,7 @@ def handle_learning_add(category: str, title: str, content: str, reasoning: str
|
|
|
24
24
|
"""
|
|
25
25
|
if category not in VALID_CATEGORIES:
|
|
26
26
|
valid = ", ".join(sorted(VALID_CATEGORIES))
|
|
27
|
-
return f"ERROR:
|
|
27
|
+
return f"ERROR: Category '{category}' invalid. Valid: {valid}"
|
|
28
28
|
result = create_learning(
|
|
29
29
|
category, title, content, reasoning=reasoning
|
|
30
30
|
)
|
|
@@ -142,7 +142,7 @@ def handle_learning_update(id: int, title: str = '', content: str = '', category
|
|
|
142
142
|
if category:
|
|
143
143
|
if category not in VALID_CATEGORIES:
|
|
144
144
|
valid = ", ".join(sorted(VALID_CATEGORIES))
|
|
145
|
-
return f"ERROR:
|
|
145
|
+
return f"ERROR: Category '{category}' invalid. Valid: {valid}"
|
|
146
146
|
kwargs["category"] = category
|
|
147
147
|
if reasoning:
|
|
148
148
|
kwargs["reasoning"] = reasoning
|
|
@@ -183,7 +183,7 @@ def handle_learning_delete(id: int) -> str:
|
|
|
183
183
|
"""Delete a learning entry by ID."""
|
|
184
184
|
deleted = delete_learning(id)
|
|
185
185
|
if not deleted:
|
|
186
|
-
return f"ERROR: Learning #{id}
|
|
186
|
+
return f"ERROR: Learning #{id} not found."
|
|
187
187
|
return f"Learning #{id} eliminado."
|
|
188
188
|
|
|
189
189
|
|
|
@@ -14,15 +14,15 @@ from db import (
|
|
|
14
14
|
def handle_reminder_create(id: str, description: str, date: str = '', category: str = 'general') -> str:
|
|
15
15
|
"""Create a new reminder. id must start with 'R'."""
|
|
16
16
|
if not id.startswith('R'):
|
|
17
|
-
return f"ERROR:
|
|
17
|
+
return f"ERROR: Reminder ID must start with 'R' (received: '{id}')."
|
|
18
18
|
|
|
19
19
|
result = create_reminder(id=id, description=description, date=date or None, category=category)
|
|
20
20
|
if not result or "error" in result:
|
|
21
|
-
error_msg = result.get("error", "
|
|
21
|
+
error_msg = result.get("error", "unknown") if isinstance(result, dict) else "unknown"
|
|
22
22
|
return f"ERROR: {error_msg}"
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
return f"
|
|
24
|
+
date_str = date if date else 'no date'
|
|
25
|
+
return f"Reminder {id} created. Date: {date_str}. Category: {category}."
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
def handle_reminder_update(id: str, description: str = '', date: str = '', status: str = '', category: str = '') -> str:
|
|
@@ -38,32 +38,32 @@ def handle_reminder_update(id: str, description: str = '', date: str = '', statu
|
|
|
38
38
|
fields['category'] = category
|
|
39
39
|
|
|
40
40
|
if not fields:
|
|
41
|
-
return f"ERROR: No
|
|
41
|
+
return f"ERROR: No fields specified to update for {id}."
|
|
42
42
|
|
|
43
43
|
result = update_reminder(id=id, **fields)
|
|
44
44
|
if not result:
|
|
45
|
-
return f"ERROR:
|
|
45
|
+
return f"ERROR: Reminder {id} not found."
|
|
46
46
|
|
|
47
47
|
changed = ', '.join(fields.keys())
|
|
48
|
-
return f"
|
|
48
|
+
return f"Reminder {id} updated: {changed}."
|
|
49
49
|
|
|
50
50
|
|
|
51
51
|
def handle_reminder_complete(id: str) -> str:
|
|
52
52
|
"""Mark a reminder as completed."""
|
|
53
53
|
result = complete_reminder(id=id)
|
|
54
54
|
if not result or "error" in result:
|
|
55
|
-
return f"ERROR:
|
|
55
|
+
return f"ERROR: Reminder {id} not found."
|
|
56
56
|
|
|
57
|
-
return f"
|
|
57
|
+
return f"Reminder {id} marked COMPLETED."
|
|
58
58
|
|
|
59
59
|
|
|
60
60
|
def handle_reminder_delete(id: str) -> str:
|
|
61
61
|
"""Delete a reminder permanently."""
|
|
62
62
|
result = delete_reminder(id=id)
|
|
63
63
|
if not result:
|
|
64
|
-
return f"ERROR:
|
|
64
|
+
return f"ERROR: Reminder {id} not found."
|
|
65
65
|
|
|
66
|
-
return f"
|
|
66
|
+
return f"Reminder {id} deleted."
|
|
67
67
|
|
|
68
68
|
|
|
69
69
|
# ── Followups ──────────────────────────────────────────────────────────────────
|
|
@@ -81,16 +81,16 @@ def handle_followup_create(id: str, description: str, date: str = '', verificati
|
|
|
81
81
|
When completed, auto-creates the next occurrence.
|
|
82
82
|
"""
|
|
83
83
|
if not id.startswith('NF'):
|
|
84
|
-
return f"ERROR:
|
|
84
|
+
return f"ERROR: Followup ID must start with 'NF' (received: '{id}')."
|
|
85
85
|
|
|
86
86
|
result = create_followup(id=id, description=description, date=date or None, verification=verification, reasoning=reasoning, recurrence=recurrence or None)
|
|
87
87
|
if not result or "error" in result:
|
|
88
|
-
error_msg = result.get("error", "
|
|
88
|
+
error_msg = result.get("error", "unknown") if isinstance(result, dict) else "unknown"
|
|
89
89
|
return f"ERROR: {error_msg}"
|
|
90
90
|
|
|
91
|
-
|
|
92
|
-
rec_str = f"
|
|
93
|
-
return f"Followup {id}
|
|
91
|
+
date_str = date if date else 'no date'
|
|
92
|
+
rec_str = f" Recurrence: {recurrence}." if recurrence else ""
|
|
93
|
+
return f"Followup {id} created. Date: {date_str}.{rec_str}"
|
|
94
94
|
|
|
95
95
|
|
|
96
96
|
def handle_followup_update(id: str, description: str = '', date: str = '', verification: str = '', status: str = '') -> str:
|
|
@@ -106,14 +106,14 @@ def handle_followup_update(id: str, description: str = '', date: str = '', verif
|
|
|
106
106
|
fields['status'] = status
|
|
107
107
|
|
|
108
108
|
if not fields:
|
|
109
|
-
return f"ERROR: No
|
|
109
|
+
return f"ERROR: No fields specified to update for {id}."
|
|
110
110
|
|
|
111
111
|
result = update_followup(id=id, **fields)
|
|
112
112
|
if not result:
|
|
113
|
-
return f"ERROR: Followup {id}
|
|
113
|
+
return f"ERROR: Followup {id} not found."
|
|
114
114
|
|
|
115
115
|
changed = ', '.join(fields.keys())
|
|
116
|
-
return f"Followup {id}
|
|
116
|
+
return f"Followup {id} updated: {changed}."
|
|
117
117
|
|
|
118
118
|
|
|
119
119
|
def handle_followup_complete(id: str, result: str = '') -> str:
|
|
@@ -128,22 +128,22 @@ def handle_followup_complete(id: str, result: str = '') -> str:
|
|
|
128
128
|
|
|
129
129
|
db_result = complete_followup(id=id, result=result)
|
|
130
130
|
if not db_result or "error" in db_result:
|
|
131
|
-
return f"ERROR: Followup {id}
|
|
131
|
+
return f"ERROR: Followup {id} not found."
|
|
132
132
|
|
|
133
133
|
# Auto-link: find decisions whose context_ref matches this followup ID
|
|
134
|
-
msg = f"Followup {id}
|
|
134
|
+
msg = f"Followup {id} marked COMPLETED."
|
|
135
135
|
if has_recurrence:
|
|
136
136
|
# The new one was auto-created by complete_followup
|
|
137
137
|
new_row = conn.execute("SELECT date FROM followups WHERE id = ?", (id,)).fetchone()
|
|
138
138
|
if new_row:
|
|
139
|
-
msg += f"
|
|
139
|
+
msg += f" Next auto-created for {new_row['date']}."
|
|
140
140
|
linked_decisions = find_decisions_by_context_ref(id)
|
|
141
141
|
if linked_decisions:
|
|
142
|
-
outcome_text = result if result else f"Followup {id}
|
|
142
|
+
outcome_text = result if result else f"Followup {id} completed"
|
|
143
143
|
for dec in linked_decisions:
|
|
144
144
|
update_decision_outcome(dec['id'], outcome_text)
|
|
145
145
|
dec_ids = ', '.join(f"#{d['id']}" for d in linked_decisions)
|
|
146
|
-
msg += f" Decision(s) {dec_ids}
|
|
146
|
+
msg += f" Decision(s) {dec_ids} updated with automatic outcome."
|
|
147
147
|
|
|
148
148
|
return msg
|
|
149
149
|
|
|
@@ -152,6 +152,6 @@ def handle_followup_delete(id: str) -> str:
|
|
|
152
152
|
"""Delete a followup permanently."""
|
|
153
153
|
result = delete_followup(id=id)
|
|
154
154
|
if not result:
|
|
155
|
-
return f"ERROR: Followup {id}
|
|
155
|
+
return f"ERROR: Followup {id} not found."
|
|
156
156
|
|
|
157
|
-
return f"Followup {id}
|
|
157
|
+
return f"Followup {id} deleted."
|