nexo-brain 0.5.0 → 0.6.0
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 +22 -7
- package/bin/nexo-brain.js +126 -1
- package/package.json +1 -1
- package/src/hooks/pre-compact.sh +65 -0
- package/src/hooks/session-start.sh +153 -17
- package/src/hooks/session-stop.sh +121 -5
- package/src/scripts/nexo-reflection.py +240 -0
- package/templates/CLAUDE.md.template +147 -7
- package/bin/nexo-brain 2.js +0 -610
- package/scripts/pre-commit-check 2.sh +0 -55
- package/templates/CLAUDE.md 2.template +0 -89
package/README.md
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# NEXO Brain — Your AI Gets a Brain
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/nexo-brain)
|
|
4
|
+
[](https://github.com/wazionapps/nexo/blob/main/benchmarks/locomo/results/)
|
|
5
|
+
[](https://github.com/snap-research/locomo/issues/33)
|
|
3
6
|
[](https://github.com/wazionapps/nexo/stargazers)
|
|
4
7
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
-
|
|
8
|
+
|
|
9
|
+
> **v0.5.0** — Highest published score on [LoCoMo benchmark](https://github.com/snap-research/locomo) (ACL 2024). F1 **0.588** — outperforms GPT-4 (0.379) by 55%. Runs on CPU. No GPU required. [Full results](benchmarks/locomo/results/)
|
|
6
10
|
|
|
7
11
|
**NEXO Brain transforms any MCP-compatible AI agent from a stateless assistant into a cognitive partner that remembers, learns, forgets, adapts, and builds a relationship with you over time.**
|
|
8
12
|
|
|
@@ -72,7 +76,7 @@ NEXO Brain uses **Ebbinghaus forgetting curves** — memories naturally fade ove
|
|
|
72
76
|
|
|
73
77
|
### Semantic Search (Finding by Meaning)
|
|
74
78
|
|
|
75
|
-
NEXO Brain doesn't search by keywords. It searches by **meaning** using vector embeddings (fastembed,
|
|
79
|
+
NEXO Brain doesn't search by keywords. It searches by **meaning** using vector embeddings (fastembed, 768 dimensions).
|
|
76
80
|
|
|
77
81
|
Example: If you search for "deploy problems", NEXO Brain will find a memory about "SSH connection timeout on production server" — even though they share zero words. This is how human associative memory works.
|
|
78
82
|
|
|
@@ -144,6 +148,9 @@ NEXO Brain v0.3.1 adds 21 cognitive tools on top of the 76 base tools, bringing
|
|
|
144
148
|
| Feature | What It Does |
|
|
145
149
|
|---------|-------------|
|
|
146
150
|
| **Pin / Snooze / Archive** | Granular lifecycle states for memories. Pin = never decays (critical knowledge). Snooze = temporarily hidden (revisit later). Archive = cold storage (searchable but inactive). |
|
|
151
|
+
| **Intelligent Chunking** | Adaptive chunking that respects sentence and paragraph boundaries. Produces semantically coherent chunks instead of arbitrary token splits, reducing retrieval noise. |
|
|
152
|
+
| **Adaptive Decay** | Decay rate adapts per memory based on access patterns: frequently-accessed memories decay slower, rarely-accessed ones fade faster. Prevents permanent clutter while keeping active knowledge sharp. |
|
|
153
|
+
| **Auto-Migration** | Formal schema migration system (schema_migrations table) tracks all database changes. Safe, reversible schema evolution for production systems — upgrades never lose data. |
|
|
147
154
|
| **Auto-Merge Duplicates** | Batch cosine deduplication during the 03:00 sleep cycle. Respects sibling discrimination — similar memories about different contexts are kept separate. |
|
|
148
155
|
| **Memory Dreaming** | Discovers hidden connections between recent memories during the 03:00 sleep cycle. Surfaces non-obvious patterns like "these three bugs all relate to the same root cause." |
|
|
149
156
|
|
|
@@ -152,6 +159,10 @@ NEXO Brain v0.3.1 adds 21 cognitive tools on top of the 76 base tools, bringing
|
|
|
152
159
|
| Feature | What It Does |
|
|
153
160
|
|---------|-------------|
|
|
154
161
|
| **HyDE Query Expansion** | Generates hypothetical answer embeddings for richer semantic search. Instead of searching for "deploy error", it imagines what a helpful memory about deploy errors would look like, then searches for that. |
|
|
162
|
+
| **Hybrid Search (FTS5+BM25+RRF)** | Combines dense vector search with BM25 keyword search via Reciprocal Rank Fusion. Outperforms pure semantic search on precise terminology and code identifiers. |
|
|
163
|
+
| **Cross-Encoder Reranking** | After initial vector retrieval, a cross-encoder model rescores candidates for precision. The top-k results are reordered by true semantic relevance before being returned to the agent. |
|
|
164
|
+
| **Multi-Query Decomposition** | Complex questions are automatically split into sub-queries. Each component is retrieved independently, then fused for a higher-quality answer — improves recall on multi-faceted prompts. |
|
|
165
|
+
| **Temporal Indexing** | Memories are indexed by time in addition to semantics. Time-sensitive queries ("what did we decide last Tuesday?") use temporal proximity scoring alongside semantic similarity. |
|
|
155
166
|
| **Spreading Activation** | Graph-based co-activation network. Memories retrieved together reinforce each other's connections, building an associative web that improves over time. |
|
|
156
167
|
| **Recall Explanations** | Transparent score breakdown for every retrieval result. Shows exactly why a memory was returned: semantic similarity, recency, access frequency, and co-activation bonuses. |
|
|
157
168
|
|
|
@@ -161,6 +172,7 @@ NEXO Brain v0.3.1 adds 21 cognitive tools on top of the 76 base tools, bringing
|
|
|
161
172
|
|---------|-------------|
|
|
162
173
|
| **Prospective Memory** | Context-triggered reminders that fire when conversation topics match, not just by date. "Remind me about X when we discuss Y" works naturally. |
|
|
163
174
|
| **Hook Auto-capture** | Extracts decisions, corrections, and factual statements from conversations automatically. You don't need to explicitly say "remember this" — the system detects what's worth storing. |
|
|
175
|
+
| **Session Summaries** | Automatic end-of-session summarization that distills key decisions, errors, and follow-ups into a compact diary entry. The next session starts with full context — not a cold slate. |
|
|
164
176
|
|
|
165
177
|
## Benchmark: LoCoMo (ACL 2024)
|
|
166
178
|
|
|
@@ -168,17 +180,20 @@ NEXO Brain was evaluated on [LoCoMo](https://github.com/snap-research/locomo) (A
|
|
|
168
180
|
|
|
169
181
|
| System | F1 | Adversarial | Hardware |
|
|
170
182
|
|---|---|---|---|
|
|
183
|
+
| **NEXO Brain v0.5.0** | **0.588** | **93.3%** | **CPU only** |
|
|
171
184
|
| GPT-4 (128K full context) | 0.379 | — | GPU cloud |
|
|
172
185
|
| Gemini Pro 1.0 | 0.313 | — | GPU cloud |
|
|
173
|
-
| **NEXO Brain** | **0.297** | **89.2%** | **CPU only** |
|
|
174
186
|
| LLaMA-3 70B | 0.295 | — | A100 GPU |
|
|
175
187
|
| GPT-3.5 + Contriever RAG | 0.283 | — | GPU |
|
|
176
188
|
|
|
189
|
+
**+55% vs GPT-4. Running entirely on CPU.**
|
|
190
|
+
|
|
177
191
|
**Key findings:**
|
|
178
|
-
-
|
|
179
|
-
-
|
|
180
|
-
-
|
|
181
|
-
-
|
|
192
|
+
- Outperforms GPT-4 (128K full context) by 55% on F1 score
|
|
193
|
+
- 93.3% adversarial rejection rate — reliably says "I don't know" when information isn't available
|
|
194
|
+
- 74.9% recall across 1,986 questions
|
|
195
|
+
- Open-domain F1: 0.637 | Multi-hop F1: 0.333 | Temporal F1: 0.326
|
|
196
|
+
- Runs on CPU with 768-dim embeddings (BAAI/bge-base-en-v1.5) — no GPU required
|
|
182
197
|
- First MCP memory server benchmarked on a peer-reviewed dataset
|
|
183
198
|
|
|
184
199
|
Full results in [`benchmarks/locomo/results/`](benchmarks/locomo/results/).
|
package/bin/nexo-brain.js
CHANGED
|
@@ -75,6 +75,120 @@ async function main() {
|
|
|
75
75
|
process.exit(1);
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
// Auto-migration: detect existing installation
|
|
79
|
+
const versionFile = path.join(NEXO_HOME, "version.json");
|
|
80
|
+
if (fs.existsSync(versionFile)) {
|
|
81
|
+
try {
|
|
82
|
+
const installed = JSON.parse(fs.readFileSync(versionFile, "utf8"));
|
|
83
|
+
const currentPkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8"));
|
|
84
|
+
const installedVersion = installed.version || "0.0.0";
|
|
85
|
+
const currentVersion = currentPkg.version;
|
|
86
|
+
|
|
87
|
+
if (installedVersion !== currentVersion) {
|
|
88
|
+
log(`Existing installation detected: v${installedVersion} → v${currentVersion}`);
|
|
89
|
+
log("Running auto-migration...");
|
|
90
|
+
|
|
91
|
+
// Update hooks
|
|
92
|
+
const hooksSrc = path.join(__dirname, "..", "src", "hooks");
|
|
93
|
+
const hooksDest = path.join(NEXO_HOME, "hooks");
|
|
94
|
+
fs.mkdirSync(hooksDest, { recursive: true });
|
|
95
|
+
["session-start.sh", "capture-session.sh", "session-stop.sh", "pre-compact.sh", "caffeinate-guard.sh"].forEach((h) => {
|
|
96
|
+
const src = path.join(hooksSrc, h);
|
|
97
|
+
const dest = path.join(hooksDest, h);
|
|
98
|
+
if (fs.existsSync(src)) {
|
|
99
|
+
fs.copyFileSync(src, dest);
|
|
100
|
+
fs.chmodSync(dest, "755");
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
log(" Hooks updated.");
|
|
104
|
+
|
|
105
|
+
// Update core Python files
|
|
106
|
+
const srcDir = path.join(__dirname, "..", "src");
|
|
107
|
+
["server.py", "db.py", "plugin_loader.py", "cognitive.py",
|
|
108
|
+
"tools_sessions.py", "tools_coordination.py", "tools_reminders.py",
|
|
109
|
+
"tools_reminders_crud.py", "tools_learnings.py", "tools_credentials.py",
|
|
110
|
+
"tools_task_history.py", "tools_menu.py"].forEach((f) => {
|
|
111
|
+
const src = path.join(srcDir, f);
|
|
112
|
+
if (fs.existsSync(src)) {
|
|
113
|
+
fs.copyFileSync(src, path.join(NEXO_HOME, f));
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
log(" Core files updated.");
|
|
117
|
+
|
|
118
|
+
// Update plugins
|
|
119
|
+
const pluginsSrc = path.join(srcDir, "plugins");
|
|
120
|
+
const pluginsDest = path.join(NEXO_HOME, "plugins");
|
|
121
|
+
fs.mkdirSync(pluginsDest, { recursive: true });
|
|
122
|
+
if (fs.existsSync(pluginsSrc)) {
|
|
123
|
+
fs.readdirSync(pluginsSrc).filter(f => f.endsWith(".py")).forEach((f) => {
|
|
124
|
+
fs.copyFileSync(path.join(pluginsSrc, f), path.join(pluginsDest, f));
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
log(" Plugins updated.");
|
|
128
|
+
|
|
129
|
+
// Update scripts
|
|
130
|
+
const scriptsSrc = path.join(srcDir, "scripts");
|
|
131
|
+
const scriptsDest = path.join(NEXO_HOME, "scripts");
|
|
132
|
+
fs.mkdirSync(scriptsDest, { recursive: true });
|
|
133
|
+
if (fs.existsSync(scriptsSrc)) {
|
|
134
|
+
fs.readdirSync(scriptsSrc).filter(f => f.endsWith(".py") || f.endsWith(".sh")).forEach((f) => {
|
|
135
|
+
fs.copyFileSync(path.join(scriptsSrc, f), path.join(scriptsDest, f));
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
log(" Scripts updated.");
|
|
139
|
+
|
|
140
|
+
// Add PreCompact hook to settings.json if missing
|
|
141
|
+
let settings = {};
|
|
142
|
+
if (fs.existsSync(CLAUDE_SETTINGS)) {
|
|
143
|
+
try { settings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS, "utf8")); } catch {}
|
|
144
|
+
}
|
|
145
|
+
if (settings.hooks && !settings.hooks.PreCompact) {
|
|
146
|
+
settings.hooks.PreCompact = [];
|
|
147
|
+
}
|
|
148
|
+
if (settings.hooks && settings.hooks.PreCompact) {
|
|
149
|
+
const hookPath = path.join(hooksDest, "pre-compact.sh");
|
|
150
|
+
if (!settings.hooks.PreCompact.some((h) => h.command && h.command.includes("pre-compact.sh"))) {
|
|
151
|
+
settings.hooks.PreCompact.push({
|
|
152
|
+
type: "command",
|
|
153
|
+
command: `bash ${hookPath}`,
|
|
154
|
+
});
|
|
155
|
+
fs.writeFileSync(CLAUDE_SETTINGS, JSON.stringify(settings, null, 2));
|
|
156
|
+
log(" PreCompact hook added to Claude Code settings.");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Update version file
|
|
161
|
+
fs.writeFileSync(versionFile, JSON.stringify({
|
|
162
|
+
version: currentVersion,
|
|
163
|
+
installed_at: installed.installed_at,
|
|
164
|
+
updated_at: new Date().toISOString(),
|
|
165
|
+
migrated_from: installedVersion,
|
|
166
|
+
}, null, 2));
|
|
167
|
+
|
|
168
|
+
// Save updated CLAUDE.md template as reference (don't overwrite user's)
|
|
169
|
+
const templateSrc = path.join(__dirname, "..", "templates", "CLAUDE.md.template");
|
|
170
|
+
if (fs.existsSync(templateSrc)) {
|
|
171
|
+
const operatorName = installed.operator_name || "NEXO";
|
|
172
|
+
let claudeMd = fs.readFileSync(templateSrc, "utf8")
|
|
173
|
+
.replace(/\{\{NAME\}\}/g, operatorName)
|
|
174
|
+
.replace(/\{\{NEXO_HOME\}\}/g, NEXO_HOME);
|
|
175
|
+
fs.writeFileSync(path.join(NEXO_HOME, "CLAUDE.md.updated"), claudeMd);
|
|
176
|
+
log(` Updated CLAUDE.md template saved to ~/.nexo/CLAUDE.md.updated`);
|
|
177
|
+
log(` Review and merge changes into your ~/.claude/CLAUDE.md if desired.`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
console.log("");
|
|
181
|
+
log(`Migration complete: v${installedVersion} → v${currentVersion}`);
|
|
182
|
+
log("Your data (memories, learnings, preferences) is untouched.");
|
|
183
|
+
console.log("");
|
|
184
|
+
rl.close();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
} catch (e) {
|
|
188
|
+
// Version file corrupt — proceed with fresh install
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
78
192
|
// Find or install Homebrew (needed for Python)
|
|
79
193
|
let hasBrew = run("which brew");
|
|
80
194
|
if (!hasBrew) {
|
|
@@ -249,6 +363,7 @@ async function main() {
|
|
|
249
363
|
JSON.stringify({
|
|
250
364
|
version: pkg.version,
|
|
251
365
|
installed_at: new Date().toISOString(),
|
|
366
|
+
operator_name: operatorName,
|
|
252
367
|
files_updated: 0,
|
|
253
368
|
}, null, 2)
|
|
254
369
|
);
|
|
@@ -421,7 +536,7 @@ Operator name: ${operatorName}
|
|
|
421
536
|
const hooksSrcDir = path.join(__dirname, "..", "src", "hooks");
|
|
422
537
|
const hooksDestDir = path.join(NEXO_HOME, "hooks");
|
|
423
538
|
fs.mkdirSync(hooksDestDir, { recursive: true });
|
|
424
|
-
["session-start.sh", "capture-session.sh", "session-stop.sh"].forEach((h) => {
|
|
539
|
+
["session-start.sh", "capture-session.sh", "session-stop.sh", "pre-compact.sh"].forEach((h) => {
|
|
425
540
|
const src = path.join(hooksSrcDir, h);
|
|
426
541
|
const dest = path.join(hooksDestDir, h);
|
|
427
542
|
if (fs.existsSync(src)) {
|
|
@@ -460,6 +575,16 @@ Operator name: ${operatorName}
|
|
|
460
575
|
settings.hooks.Stop.push(stopHook);
|
|
461
576
|
}
|
|
462
577
|
|
|
578
|
+
// PreCompact hook (saves context before conversation compression)
|
|
579
|
+
if (!settings.hooks.PreCompact) settings.hooks.PreCompact = [];
|
|
580
|
+
const preCompactHook = {
|
|
581
|
+
type: "command",
|
|
582
|
+
command: `bash ${path.join(hooksDestDir, "pre-compact.sh")}`,
|
|
583
|
+
};
|
|
584
|
+
if (!settings.hooks.PreCompact.some((h) => h.command && h.command.includes("pre-compact.sh"))) {
|
|
585
|
+
settings.hooks.PreCompact.push(preCompactHook);
|
|
586
|
+
}
|
|
587
|
+
|
|
463
588
|
const settingsDir = path.dirname(CLAUDE_SETTINGS);
|
|
464
589
|
fs.mkdirSync(settingsDir, { recursive: true });
|
|
465
590
|
fs.writeFileSync(CLAUDE_SETTINGS, JSON.stringify(settings, null, 2));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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": {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# NEXO PreCompact hook — saves context before Claude Code compacts the conversation.
|
|
3
|
+
# Compaction loses context silently. This hook ensures the operator writes a checkpoint
|
|
4
|
+
# before that happens, and saves the last known state to a recovery file.
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
|
|
8
|
+
NEXO_NAME="${NEXO_NAME:-NEXO}"
|
|
9
|
+
CHECKPOINT_FILE="$NEXO_HOME/coordination/pre-compact-checkpoint.json"
|
|
10
|
+
|
|
11
|
+
mkdir -p "$NEXO_HOME/coordination"
|
|
12
|
+
|
|
13
|
+
# Save current state to checkpoint file
|
|
14
|
+
python3 -c "
|
|
15
|
+
import json, os, sys
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
|
|
18
|
+
nexo_home = os.environ.get('NEXO_HOME', os.path.expanduser('~/.nexo'))
|
|
19
|
+
db_path = os.path.join(nexo_home, 'nexo.db')
|
|
20
|
+
|
|
21
|
+
checkpoint = {
|
|
22
|
+
'timestamp': datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S'),
|
|
23
|
+
'active_sessions': [],
|
|
24
|
+
'last_context_hints': [],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
import sqlite3
|
|
29
|
+
if os.path.exists(db_path):
|
|
30
|
+
db = sqlite3.connect(db_path, timeout=5)
|
|
31
|
+
db.row_factory = sqlite3.Row
|
|
32
|
+
sessions = db.execute(
|
|
33
|
+
'SELECT sid, task, started FROM sessions WHERE completed=0'
|
|
34
|
+
).fetchall()
|
|
35
|
+
checkpoint['active_sessions'] = [
|
|
36
|
+
{'sid': s['sid'], 'task': s['task'], 'started': s['started']}
|
|
37
|
+
for s in sessions
|
|
38
|
+
]
|
|
39
|
+
# Get last diary drafts for context
|
|
40
|
+
try:
|
|
41
|
+
drafts = db.execute(
|
|
42
|
+
'SELECT sid, last_context_hint, tasks_seen FROM session_diary_draft '
|
|
43
|
+
'ORDER BY updated_at DESC LIMIT 3'
|
|
44
|
+
).fetchall()
|
|
45
|
+
checkpoint['last_context_hints'] = [
|
|
46
|
+
{'sid': d['sid'], 'hint': d['last_context_hint'], 'tasks': d['tasks_seen']}
|
|
47
|
+
for d in drafts
|
|
48
|
+
]
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
db.close()
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
with open('$CHECKPOINT_FILE', 'w') as f:
|
|
56
|
+
json.dump(checkpoint, f, indent=2)
|
|
57
|
+
" 2>/dev/null || true
|
|
58
|
+
|
|
59
|
+
# Emit hook response with systemMessage
|
|
60
|
+
cat << HOOKEOF
|
|
61
|
+
{
|
|
62
|
+
"decision": "approve",
|
|
63
|
+
"systemMessage": "PRE-COMPACT HOOK — Context is about to be compressed. BEFORE continuing:\n\n1. **Write a diary draft NOW** — call nexo_session_diary_write with what you've done so far, decisions made, and current mental state. This is your lifeline after compaction.\n2. **Note your current task** — after compaction you may lose the thread. Write it down in the diary.\n3. **Check pending followups** — if you promised to do something, make sure it's recorded before context is lost.\n4. **Read the checkpoint** after compaction: ${NEXO_HOME}/coordination/pre-compact-checkpoint.json\n\nDo NOT skip this. Compaction without a diary = starting from zero."
|
|
64
|
+
}
|
|
65
|
+
HOOKEOF
|
|
@@ -1,27 +1,163 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
# NEXO SessionStart hook —
|
|
3
|
-
#
|
|
2
|
+
# NEXO SessionStart hook — generates a comprehensive briefing.
|
|
3
|
+
# Reads SQLite directly for reminders, followups, active sessions.
|
|
4
|
+
# Caches output for 1 hour to avoid regenerating on rapid successive sessions.
|
|
5
|
+
set -euo pipefail
|
|
4
6
|
|
|
5
7
|
NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
|
|
6
8
|
BRIEFING_FILE="$NEXO_HOME/coordination/session-briefing.txt"
|
|
9
|
+
MAX_AGE_SECONDS=3600 # 1 hour cache
|
|
7
10
|
|
|
8
11
|
mkdir -p "$NEXO_HOME/coordination"
|
|
9
12
|
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
if [
|
|
13
|
-
|
|
13
|
+
# If briefing exists and is less than 1 hour old, skip regeneration
|
|
14
|
+
if [ -f "$BRIEFING_FILE" ]; then
|
|
15
|
+
if [ "$(uname)" = "Darwin" ]; then
|
|
16
|
+
file_age=$(( $(date +%s) - $(stat -f %m "$BRIEFING_FILE") ))
|
|
17
|
+
else
|
|
18
|
+
file_age=$(( $(date +%s) - $(stat -c %Y "$BRIEFING_FILE") ))
|
|
19
|
+
fi
|
|
20
|
+
if [ "$file_age" -lt "$MAX_AGE_SECONDS" ]; then
|
|
21
|
+
exit 0
|
|
22
|
+
fi
|
|
14
23
|
fi
|
|
15
24
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
25
|
+
TODAY=$(date +%Y-%m-%d)
|
|
26
|
+
WEEKDAY=$(date +%A)
|
|
27
|
+
|
|
28
|
+
# Generate briefing from SQLite
|
|
29
|
+
python3 -c "
|
|
30
|
+
import json, os, sys
|
|
31
|
+
from datetime import date
|
|
32
|
+
|
|
33
|
+
today_str = '$TODAY'
|
|
34
|
+
weekday = '$WEEKDAY'
|
|
35
|
+
nexo_home = os.environ.get('NEXO_HOME', os.path.expanduser('~/.nexo'))
|
|
36
|
+
db_path = os.path.join(nexo_home, 'nexo.db')
|
|
37
|
+
|
|
38
|
+
lines = []
|
|
39
|
+
lines.append(f'## Date: {today_str} ({weekday})')
|
|
40
|
+
lines.append('')
|
|
41
|
+
|
|
42
|
+
# Read from SQLite
|
|
43
|
+
reminders_rows = []
|
|
44
|
+
followups_rows = []
|
|
45
|
+
sessions = []
|
|
46
|
+
sqlite_ok = True
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
import sqlite3
|
|
50
|
+
if not os.path.exists(db_path):
|
|
51
|
+
sqlite_ok = False
|
|
52
|
+
else:
|
|
53
|
+
db = sqlite3.connect(db_path, timeout=10)
|
|
54
|
+
db.execute('PRAGMA journal_mode=WAL')
|
|
55
|
+
db.execute('PRAGMA busy_timeout=10000')
|
|
56
|
+
db.row_factory = sqlite3.Row
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
reminders_rows = [dict(r) for r in db.execute(
|
|
60
|
+
'SELECT id, date, description, status, category FROM reminders '
|
|
61
|
+
'WHERE status NOT LIKE \"%COMPLETADO%\" AND status NOT LIKE \"%ELIMINADO%\" '
|
|
62
|
+
'AND status NOT LIKE \"%COMPLETED%\" AND status NOT LIKE \"%DELETED%\"'
|
|
63
|
+
).fetchall()]
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
followups_rows = [dict(r) for r in db.execute(
|
|
69
|
+
'SELECT id, date, description, status FROM followups '
|
|
70
|
+
'WHERE status NOT LIKE \"%COMPLETADO%\" AND status NOT LIKE \"%COMPLETED%\"'
|
|
71
|
+
).fetchall()]
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
rows = db.execute(
|
|
77
|
+
'SELECT sid, task, started FROM sessions '
|
|
78
|
+
'WHERE completed=0 AND (strftime(\"%s\",\"now\") - last_update) < 900'
|
|
79
|
+
).fetchall()
|
|
80
|
+
sessions = [{'sid': r['sid'], 'task': r['task'], 'started': r['started'][:16]} for r in rows]
|
|
81
|
+
except Exception:
|
|
82
|
+
pass
|
|
21
83
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
84
|
+
db.close()
|
|
85
|
+
except Exception:
|
|
86
|
+
sqlite_ok = False
|
|
87
|
+
|
|
88
|
+
if not sqlite_ok:
|
|
89
|
+
lines.append('Database not initialized yet. Run nexo_startup to begin.')
|
|
90
|
+
lines.append('')
|
|
91
|
+
print('\n'.join(lines))
|
|
92
|
+
sys.exit(0)
|
|
93
|
+
|
|
94
|
+
# Overdue reminders
|
|
95
|
+
lines.append('## Overdue Reminders')
|
|
96
|
+
found = False
|
|
97
|
+
for r in reminders_rows:
|
|
98
|
+
rdate = r.get('date', '')
|
|
99
|
+
if rdate and rdate[:10] < today_str:
|
|
100
|
+
try:
|
|
101
|
+
delta = (date.fromisoformat(today_str) - date.fromisoformat(rdate[:10])).days
|
|
102
|
+
except:
|
|
103
|
+
delta = '?'
|
|
104
|
+
desc = (r.get('description', '') or '')[:120]
|
|
105
|
+
lines.append(f'- [{r[\"id\"]}] {rdate} {desc} — {delta} day(s) overdue')
|
|
106
|
+
found = True
|
|
107
|
+
if not found:
|
|
108
|
+
lines.append('NONE')
|
|
109
|
+
lines.append('')
|
|
110
|
+
|
|
111
|
+
# Today's reminders
|
|
112
|
+
lines.append('## Reminders Due Today')
|
|
113
|
+
found = False
|
|
114
|
+
for r in reminders_rows:
|
|
115
|
+
rdate = r.get('date', '')
|
|
116
|
+
if rdate and rdate[:10] == today_str:
|
|
117
|
+
desc = (r.get('description', '') or '')[:120]
|
|
118
|
+
lines.append(f'- [{r[\"id\"]}] {desc}')
|
|
119
|
+
found = True
|
|
120
|
+
if not found:
|
|
121
|
+
lines.append('NONE')
|
|
122
|
+
lines.append('')
|
|
123
|
+
|
|
124
|
+
# Pending followups (due today or overdue)
|
|
125
|
+
lines.append('## Followups Due Today or Overdue')
|
|
126
|
+
found = False
|
|
127
|
+
for r in followups_rows:
|
|
128
|
+
fdate = r.get('date', '')
|
|
129
|
+
if fdate and fdate[:10] <= today_str:
|
|
130
|
+
desc = (r.get('description', '') or '')[:100]
|
|
131
|
+
lines.append(f'- [{r[\"id\"]}] {fdate} {desc}')
|
|
132
|
+
found = True
|
|
133
|
+
if not found:
|
|
134
|
+
lines.append('NONE')
|
|
135
|
+
lines.append('')
|
|
136
|
+
|
|
137
|
+
# Active sessions
|
|
138
|
+
lines.append('## Active Sessions')
|
|
139
|
+
if sessions:
|
|
140
|
+
for s in sessions:
|
|
141
|
+
lines.append(f'- [{s[\"sid\"]}] {s[\"task\"]} (since {s[\"started\"]})')
|
|
142
|
+
else:
|
|
143
|
+
lines.append('NONE')
|
|
144
|
+
lines.append('')
|
|
145
|
+
|
|
146
|
+
# Last self-audit
|
|
147
|
+
audit_file = os.path.join(nexo_home, 'logs', 'self-audit-summary.json')
|
|
148
|
+
if os.path.exists(audit_file):
|
|
149
|
+
try:
|
|
150
|
+
audit = json.load(open(audit_file))
|
|
151
|
+
lines.append('## Last Self-Audit')
|
|
152
|
+
lines.append(json.dumps(audit, indent=2)[:500])
|
|
153
|
+
lines.append('')
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
print('\n'.join(lines))
|
|
158
|
+
" > "$BRIEFING_FILE" 2>/dev/null
|
|
159
|
+
|
|
160
|
+
# If generation failed, write minimal briefing
|
|
161
|
+
if [ ! -s "$BRIEFING_FILE" ]; then
|
|
162
|
+
echo "## Briefing unavailable — generation error. Use nexo_reminders MCP for fresh data." > "$BRIEFING_FILE"
|
|
163
|
+
fi
|
|
@@ -1,11 +1,127 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
# NEXO Stop hook —
|
|
3
|
-
#
|
|
2
|
+
# NEXO Stop hook — Full post-mortem and session closure.
|
|
3
|
+
# Injects a systemMessage with mandatory self-critique instructions.
|
|
4
|
+
# After emitting the hook response, writes a fallback buffer entry
|
|
5
|
+
# and triggers intra-day reflection if enough sessions have accumulated.
|
|
6
|
+
set -euo pipefail
|
|
4
7
|
|
|
5
8
|
NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
|
|
9
|
+
NEXO_NAME="${NEXO_NAME:-NEXO}"
|
|
10
|
+
|
|
11
|
+
# 0. Refresh diary draft with latest changes/decisions (best-effort)
|
|
12
|
+
python3 -c "
|
|
13
|
+
import sys, json, os
|
|
14
|
+
sys.path.insert(0, os.environ.get('NEXO_HOME', os.path.expanduser('~/.nexo')))
|
|
15
|
+
os.environ['NEXO_SKIP_FS_INDEX'] = '1'
|
|
16
|
+
try:
|
|
17
|
+
from db import init_db, get_db, get_active_sessions, upsert_diary_draft, get_diary_draft
|
|
18
|
+
init_db()
|
|
19
|
+
conn = get_db()
|
|
20
|
+
sessions = get_active_sessions()
|
|
21
|
+
for s in sessions:
|
|
22
|
+
sid = s['sid']
|
|
23
|
+
draft = get_diary_draft(sid)
|
|
24
|
+
if not draft:
|
|
25
|
+
continue
|
|
26
|
+
change_ids = [r[0] for r in conn.execute('SELECT id FROM change_log WHERE session_id = ?', (sid,)).fetchall()]
|
|
27
|
+
decision_ids = [r[0] for r in conn.execute('SELECT id FROM decisions WHERE session_id = ?', (sid,)).fetchall()]
|
|
28
|
+
upsert_diary_draft(
|
|
29
|
+
sid=sid,
|
|
30
|
+
tasks_seen=draft['tasks_seen'],
|
|
31
|
+
change_ids=json.dumps(change_ids),
|
|
32
|
+
decision_ids=json.dumps(decision_ids),
|
|
33
|
+
last_context_hint=draft['last_context_hint'],
|
|
34
|
+
heartbeat_count=draft['heartbeat_count'],
|
|
35
|
+
summary_draft=draft['summary_draft'],
|
|
36
|
+
)
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
" 2>/dev/null || true
|
|
40
|
+
|
|
41
|
+
# 1. Emit hook response (must be first output — Claude Code reads this)
|
|
42
|
+
cat << HOOKEOF
|
|
43
|
+
{
|
|
44
|
+
"decision": "approve",
|
|
45
|
+
"systemMessage": "STOP HOOK — MANDATORY POST-MORTEM before ending (do NOT ask permission, do NOT skip):\n\n## 1. SELF-CRITIQUE (MANDATORY — write to session diary)\nAnswer these questions in the self_critique field of nexo_session_diary_write:\n- Did the user have to ask me for something I should have detected or done on my own?\n- Did I wait for the user to tell me something I could have verified proactively?\n- Are there systems/states I can check next session without being asked?\n- Did I repeat an error that already had a registered learning?\n- What would I do differently if I repeated this session?\nIf any answer is YES — write the specific rule that would prevent repetition.\nIf the session was flawless, write 'No self-critique — clean session.'\n\n## 2. SESSION BUFFER\nIf the session was NOT trivial, append ONE JSON line to ${NEXO_HOME}/brain/session_buffer.jsonl:\n{\"ts\":\"YYYY-MM-DDTHH:MM:SS\",\"tasks\":[...],\"decisions\":[...],\"user_patterns\":[...],\"files_modified\":[...],\"errors_resolved\":[...],\"self_critique\":\"short summary of what I should have done better\",\"mood\":\"focused|impatient|exploratory|frustrated|satisfied|neutral\",\"source\":\"claude\"}\n\n## 3. FOLLOWUPS\nIf there were deploys/cron changes/fixes — nexo_followup_create with verification date.\n\n## 4. PROACTIVE SEEDS\nBefore closing, think: what can I leave prepared so the next session starts doing useful work without the user asking? Create followups with date=tomorrow for proactive verifications.\n\nEntities, preferences, learnings — only if they appeared during the session."
|
|
46
|
+
}
|
|
47
|
+
HOOKEOF
|
|
48
|
+
|
|
49
|
+
# 2. Direct session buffer fallback (Claude's MCP write is better but not guaranteed)
|
|
6
50
|
BUFFER="$NEXO_HOME/brain/session_buffer.jsonl"
|
|
51
|
+
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%S")
|
|
52
|
+
|
|
53
|
+
# Check if Claude already wrote to the buffer in the last 60 seconds
|
|
54
|
+
SKIP_FALLBACK=false
|
|
55
|
+
if [ -f "$BUFFER" ]; then
|
|
56
|
+
LAST_SOURCE=$(python3 -c "
|
|
57
|
+
import json, sys
|
|
58
|
+
from datetime import datetime, timedelta
|
|
59
|
+
try:
|
|
60
|
+
lines = open('$BUFFER').readlines()
|
|
61
|
+
if lines:
|
|
62
|
+
d = json.loads(lines[-1])
|
|
63
|
+
ts = d.get('ts','')
|
|
64
|
+
src = d.get('source','')
|
|
65
|
+
entry_dt = datetime.strptime(ts, '%Y-%m-%dT%H:%M:%S')
|
|
66
|
+
if datetime.utcnow() - entry_dt < timedelta(seconds=60) and src == 'claude':
|
|
67
|
+
print('skip')
|
|
68
|
+
else:
|
|
69
|
+
print('write')
|
|
70
|
+
else:
|
|
71
|
+
print('write')
|
|
72
|
+
except:
|
|
73
|
+
print('write')
|
|
74
|
+
" 2>/dev/null || echo "write")
|
|
75
|
+
if [ "$LAST_SOURCE" = "skip" ]; then
|
|
76
|
+
SKIP_FALLBACK=true
|
|
77
|
+
fi
|
|
78
|
+
fi
|
|
79
|
+
|
|
80
|
+
if [ "$SKIP_FALLBACK" = false ]; then
|
|
81
|
+
mkdir -p "$(dirname "$BUFFER")"
|
|
82
|
+
echo "{\"ts\":\"$TIMESTAMP\",\"tasks\":[\"session ended\"],\"decisions\":[],\"user_patterns\":[],\"files_modified\":[],\"errors_resolved\":[],\"self_critique\":\"hook-fallback, no self-critique captured\",\"mood\":\"unknown\",\"source\":\"hook-fallback\"}" >> "$BUFFER" 2>/dev/null
|
|
83
|
+
fi
|
|
84
|
+
|
|
85
|
+
# 3. Intra-day reflection trigger
|
|
86
|
+
# Check if buffer has >=3 sessions AND last reflection was >4h ago
|
|
87
|
+
REFLECTION_SCRIPT="$NEXO_HOME/scripts/nexo-reflection.py"
|
|
88
|
+
REFLECTION_STATE="$NEXO_HOME/coordination/reflection-log.json"
|
|
89
|
+
TRIGGER_THRESHOLD=3
|
|
90
|
+
|
|
91
|
+
if [ -f "$BUFFER" ] && [ -f "$REFLECTION_SCRIPT" ]; then
|
|
92
|
+
LINE_COUNT=$(wc -l < "$BUFFER" | tr -d ' ')
|
|
7
93
|
|
|
8
|
-
|
|
94
|
+
if [ "$LINE_COUNT" -ge "$TRIGGER_THRESHOLD" ]; then
|
|
95
|
+
SHOULD_REFLECT=true
|
|
96
|
+
if [ -f "$REFLECTION_STATE" ]; then
|
|
97
|
+
LAST_TS=$(python3 -c "
|
|
98
|
+
import json
|
|
99
|
+
from datetime import datetime, timedelta
|
|
100
|
+
try:
|
|
101
|
+
log = json.load(open('$REFLECTION_STATE'))
|
|
102
|
+
if log:
|
|
103
|
+
last = log[-1]['timestamp']
|
|
104
|
+
last_dt = datetime.strptime(last, '%Y-%m-%d %H:%M')
|
|
105
|
+
if datetime.now() - last_dt < timedelta(hours=4):
|
|
106
|
+
print('too_recent')
|
|
107
|
+
else:
|
|
108
|
+
print('ok')
|
|
109
|
+
else:
|
|
110
|
+
print('ok')
|
|
111
|
+
except:
|
|
112
|
+
print('ok')
|
|
113
|
+
" 2>/dev/null)
|
|
114
|
+
if [ "$LAST_TS" = "too_recent" ]; then
|
|
115
|
+
SHOULD_REFLECT=false
|
|
116
|
+
fi
|
|
117
|
+
fi
|
|
9
118
|
|
|
10
|
-
|
|
11
|
-
|
|
119
|
+
if [ "$SHOULD_REFLECT" = true ]; then
|
|
120
|
+
# Find Python — prefer the one used by NEXO
|
|
121
|
+
PYTHON=$(which python3 2>/dev/null || echo "/usr/bin/python3")
|
|
122
|
+
nohup "$PYTHON" "$REFLECTION_SCRIPT" \
|
|
123
|
+
>> "$NEXO_HOME/logs/reflection-stdout.log" \
|
|
124
|
+
2>> "$NEXO_HOME/logs/reflection-stderr.log" &
|
|
125
|
+
fi
|
|
126
|
+
fi
|
|
127
|
+
fi
|