nexo-brain 2.6.14 → 2.6.16
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/.claude-plugin/plugin.json +1 -1
- package/README.md +41 -5
- package/package.json +1 -1
- package/src/agent_runner.py +70 -2
- package/src/bootstrap_docs.py +13 -2
- package/src/client_sync.py +140 -0
- package/src/cognitive/__init__.py +4 -0
- package/src/cognitive/_core.py +80 -0
- package/src/cognitive/_decay.py +28 -11
- package/src/cognitive/_ingest.py +44 -22
- package/src/cognitive/_memory.py +8 -0
- package/src/cognitive/_search.py +71 -11
- package/src/dashboard/app.py +15 -8
- package/src/db/_schema.py +10 -0
- package/src/db/_sessions.py +13 -6
- package/src/doctor/providers/runtime.py +60 -5
- package/src/hooks/capture-tool-logs.sh +2 -2
- package/src/hooks/inbox-hook.sh +1 -1
- package/src/plugins/cognitive_memory.py +14 -6
- package/src/scripts/deep-sleep/collect.py +181 -0
- package/src/scripts/deep-sleep/synthesize-prompt.md +5 -0
- package/src/scripts/deep-sleep/synthesize.py +2 -0
- package/src/scripts/nexo-inbox-hook.sh +1 -1
- package/src/scripts/nexo-reflection.py +7 -4
- package/src/server.py +13 -6
- package/src/tools_sessions.py +22 -5
- package/templates/CODEX.AGENTS.md.template +2 -2
package/src/hooks/inbox-hook.sh
CHANGED
|
@@ -32,7 +32,7 @@ DB="$NEXO_HOME/data/nexo.db"
|
|
|
32
32
|
mkdir -p "$NEXO_HOME/data"
|
|
33
33
|
[ -f "$DB" ] || exit 0
|
|
34
34
|
|
|
35
|
-
NEXO_SID=$(sqlite3 "$DB" "SELECT sid FROM sessions WHERE claude_session_id = '${CLAUDE_SID}' AND last_update_epoch > (strftime('%s','now') - 900) ORDER BY last_update_epoch DESC LIMIT 1;" 2>/dev/null)
|
|
35
|
+
NEXO_SID=$(sqlite3 "$DB" "SELECT sid FROM sessions WHERE (external_session_id = '${CLAUDE_SID}' OR claude_session_id = '${CLAUDE_SID}') AND last_update_epoch > (strftime('%s','now') - 900) ORDER BY last_update_epoch DESC LIMIT 1;" 2>/dev/null)
|
|
36
36
|
[ -z "$NEXO_SID" ] && exit 0
|
|
37
37
|
|
|
38
38
|
# 5. Check inbox — messages addressed to this session or broadcast
|
|
@@ -19,8 +19,8 @@ def handle_cognitive_retrieve(
|
|
|
19
19
|
source_type: str = "",
|
|
20
20
|
domain: str = "",
|
|
21
21
|
include_archived: bool = False,
|
|
22
|
-
use_hyde: bool =
|
|
23
|
-
spreading_depth: int =
|
|
22
|
+
use_hyde: bool | None = None,
|
|
23
|
+
spreading_depth: int | None = None,
|
|
24
24
|
) -> str:
|
|
25
25
|
"""RAG query over cognitive memory (STM + LTM). Triggers rehearsal on retrieved memories.
|
|
26
26
|
|
|
@@ -32,8 +32,8 @@ def handle_cognitive_retrieve(
|
|
|
32
32
|
source_type: Filter by source type e.g. "change", "learning", "diary" (default: all)
|
|
33
33
|
domain: Filter by domain e.g. "project-a", "shopify" (default: all)
|
|
34
34
|
include_archived: If True, also search archived memories (default False)
|
|
35
|
-
use_hyde: If True,
|
|
36
|
-
spreading_depth: If >0, boost co-activated neighbors
|
|
35
|
+
use_hyde: If True/False, force HyDE on/off. If omitted, NEXO auto-enables it for conceptual queries.
|
|
36
|
+
spreading_depth: If >0, boost co-activated neighbors directly. If omitted, NEXO may auto-enable shallow spreading for multi-hop queries.
|
|
37
37
|
"""
|
|
38
38
|
if not query or not query.strip():
|
|
39
39
|
return "ERROR: query is required."
|
|
@@ -57,10 +57,14 @@ def handle_cognitive_retrieve(
|
|
|
57
57
|
|
|
58
58
|
formatted = cognitive.format_results(results)
|
|
59
59
|
mode_parts = [f"stores={stores}", f"min_score={min_score}"]
|
|
60
|
-
if use_hyde:
|
|
60
|
+
if use_hyde is True:
|
|
61
61
|
mode_parts.append("hyde=ON")
|
|
62
|
-
|
|
62
|
+
elif use_hyde is None:
|
|
63
|
+
mode_parts.append("hyde=AUTO")
|
|
64
|
+
if spreading_depth and spreading_depth > 0:
|
|
63
65
|
mode_parts.append(f"spreading={spreading_depth}")
|
|
66
|
+
elif spreading_depth is None:
|
|
67
|
+
mode_parts.append("spreading=AUTO")
|
|
64
68
|
header = f"COGNITIVE RETRIEVE — query: '{query}' | {len(results)} results ({', '.join(mode_parts)})\n\n"
|
|
65
69
|
return header + formatted
|
|
66
70
|
|
|
@@ -76,6 +80,10 @@ def handle_cognitive_stats() -> str:
|
|
|
76
80
|
f" LTM dormant: {stats['ltm_dormant']}",
|
|
77
81
|
f" Avg STM strength: {stats['avg_stm_strength']:.3f}",
|
|
78
82
|
f" Avg LTM strength: {stats['avg_ltm_strength']:.3f}",
|
|
83
|
+
f" Avg STM stability: {stats.get('avg_stm_stability', 0.0):.3f}",
|
|
84
|
+
f" Avg LTM stability: {stats.get('avg_ltm_stability', 0.0):.3f}",
|
|
85
|
+
f" Avg STM difficulty: {stats.get('avg_stm_difficulty', 0.0):.3f}",
|
|
86
|
+
f" Avg LTM difficulty: {stats.get('avg_ltm_difficulty', 0.0):.3f}",
|
|
79
87
|
f" Total retrievals: {stats['total_retrievals']}",
|
|
80
88
|
f" Avg retrieval score: {stats['avg_retrieval_score']:.3f}",
|
|
81
89
|
]
|
|
@@ -16,6 +16,7 @@ import os
|
|
|
16
16
|
import re
|
|
17
17
|
import sqlite3
|
|
18
18
|
import sys
|
|
19
|
+
from collections import Counter
|
|
19
20
|
from datetime import datetime, timedelta
|
|
20
21
|
from pathlib import Path
|
|
21
22
|
|
|
@@ -344,6 +345,172 @@ def collect_trust_score() -> list[dict]:
|
|
|
344
345
|
)
|
|
345
346
|
|
|
346
347
|
|
|
348
|
+
def _parse_diary_created_at(value) -> datetime | None:
|
|
349
|
+
if value in (None, ""):
|
|
350
|
+
return None
|
|
351
|
+
try:
|
|
352
|
+
if isinstance(value, (int, float)) or (isinstance(value, str) and str(value).strip().isdigit()):
|
|
353
|
+
return datetime.fromtimestamp(float(value))
|
|
354
|
+
except Exception:
|
|
355
|
+
return None
|
|
356
|
+
try:
|
|
357
|
+
return datetime.fromisoformat(str(value).replace("Z", "+00:00").replace("+00:00", ""))
|
|
358
|
+
except Exception:
|
|
359
|
+
return None
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _sample_evenly(rows: list[dict], limit: int) -> list[dict]:
|
|
363
|
+
if limit <= 0 or not rows:
|
|
364
|
+
return []
|
|
365
|
+
if len(rows) <= limit:
|
|
366
|
+
return list(rows)
|
|
367
|
+
if limit == 1:
|
|
368
|
+
return [rows[-1]]
|
|
369
|
+
step = (len(rows) - 1) / float(limit - 1)
|
|
370
|
+
indices = sorted({round(i * step) for i in range(limit)})
|
|
371
|
+
sampled = [rows[idx] for idx in indices]
|
|
372
|
+
i = 0
|
|
373
|
+
while len(sampled) < limit and i < len(rows):
|
|
374
|
+
if rows[i] not in sampled:
|
|
375
|
+
sampled.append(rows[i])
|
|
376
|
+
i += 1
|
|
377
|
+
return sampled[:limit]
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _compact_diary_row(row: dict) -> dict:
|
|
381
|
+
created = _parse_diary_created_at(row.get("created_at"))
|
|
382
|
+
return {
|
|
383
|
+
"session_id": row.get("session_id", ""),
|
|
384
|
+
"created_at": created.isoformat() if created else str(row.get("created_at", "")),
|
|
385
|
+
"domain": row.get("domain", "") or "",
|
|
386
|
+
"mental_state": row.get("mental_state", "") or "",
|
|
387
|
+
"summary": str(row.get("summary", "") or "")[:240],
|
|
388
|
+
"self_critique": str(row.get("self_critique", "") or "")[:240],
|
|
389
|
+
"source": row.get("source", "") or "",
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def collect_long_horizon_context(
|
|
394
|
+
target_date: str,
|
|
395
|
+
*,
|
|
396
|
+
horizon_days: int = 60,
|
|
397
|
+
recent_days: int = 14,
|
|
398
|
+
max_diaries: int = 20,
|
|
399
|
+
max_sessions: int = 12,
|
|
400
|
+
) -> dict:
|
|
401
|
+
"""Build long-horizon context blending recent and older evidence.
|
|
402
|
+
|
|
403
|
+
Strategy:
|
|
404
|
+
- recent 70% from the last `recent_days`
|
|
405
|
+
- older 30% sampled evenly from the rest of the `horizon_days` window
|
|
406
|
+
"""
|
|
407
|
+
target_day = datetime.strptime(target_date, "%Y-%m-%d")
|
|
408
|
+
horizon_start = target_day - timedelta(days=horizon_days)
|
|
409
|
+
recent_start = target_day - timedelta(days=recent_days)
|
|
410
|
+
|
|
411
|
+
diary_rows = safe_query(
|
|
412
|
+
NEXO_DB,
|
|
413
|
+
"SELECT session_id, created_at, summary, mental_state, domain, self_critique, source "
|
|
414
|
+
"FROM session_diary ORDER BY created_at ASC"
|
|
415
|
+
)
|
|
416
|
+
compact_diaries = []
|
|
417
|
+
for row in diary_rows:
|
|
418
|
+
created = _parse_diary_created_at(row.get("created_at"))
|
|
419
|
+
if not created:
|
|
420
|
+
continue
|
|
421
|
+
if not (horizon_start <= created < target_day):
|
|
422
|
+
continue
|
|
423
|
+
compact_diaries.append(_compact_diary_row(row))
|
|
424
|
+
|
|
425
|
+
recent_diaries = [row for row in compact_diaries if _parse_diary_created_at(row.get("created_at")) and _parse_diary_created_at(row.get("created_at")) >= recent_start]
|
|
426
|
+
older_diaries = [row for row in compact_diaries if row not in recent_diaries]
|
|
427
|
+
recent_quota = max(1, round(max_diaries * 0.7))
|
|
428
|
+
older_quota = max(0, max_diaries - recent_quota)
|
|
429
|
+
sampled_diaries = recent_diaries[-recent_quota:] + _sample_evenly(older_diaries, older_quota)
|
|
430
|
+
sampled_diaries.sort(key=lambda row: row.get("created_at", ""))
|
|
431
|
+
|
|
432
|
+
recurring_domains = Counter(row["domain"] for row in compact_diaries if row.get("domain"))
|
|
433
|
+
recurring_states = Counter(row["mental_state"] for row in compact_diaries if row.get("mental_state"))
|
|
434
|
+
recurring_critiques = Counter(row["self_critique"] for row in compact_diaries if row.get("self_critique"))
|
|
435
|
+
|
|
436
|
+
learning_rows = safe_query(
|
|
437
|
+
NEXO_DB,
|
|
438
|
+
"SELECT category, title, content, created_at, updated_at, reasoning, prevention, applies_to "
|
|
439
|
+
"FROM learnings ORDER BY COALESCE(updated_at, created_at) DESC LIMIT 120"
|
|
440
|
+
)
|
|
441
|
+
long_horizon_learnings = []
|
|
442
|
+
for row in learning_rows:
|
|
443
|
+
long_horizon_learnings.append({
|
|
444
|
+
"category": row.get("category", ""),
|
|
445
|
+
"title": str(row.get("title", "") or "")[:140],
|
|
446
|
+
"content": str(row.get("content", "") or "")[:260],
|
|
447
|
+
"reasoning": str(row.get("reasoning", "") or "")[:180],
|
|
448
|
+
"prevention": str(row.get("prevention", "") or "")[:180],
|
|
449
|
+
"applies_to": str(row.get("applies_to", "") or "")[:180],
|
|
450
|
+
"updated_at": str(row.get("updated_at", "") or row.get("created_at", "")),
|
|
451
|
+
})
|
|
452
|
+
long_horizon_learnings = long_horizon_learnings[:24]
|
|
453
|
+
|
|
454
|
+
transcript_candidates: list[dict] = []
|
|
455
|
+
transcript_files: list[tuple[str, Path]] = [
|
|
456
|
+
("claude_code", path) for path in find_claude_session_files()
|
|
457
|
+
] + [
|
|
458
|
+
("codex", path) for path in find_codex_session_files()
|
|
459
|
+
]
|
|
460
|
+
horizon_end = target_day
|
|
461
|
+
for client, path in transcript_files:
|
|
462
|
+
try:
|
|
463
|
+
modified = datetime.fromtimestamp(path.stat().st_mtime)
|
|
464
|
+
except OSError:
|
|
465
|
+
continue
|
|
466
|
+
if not (horizon_start <= modified < horizon_end):
|
|
467
|
+
continue
|
|
468
|
+
transcript_candidates.append({
|
|
469
|
+
"client": client,
|
|
470
|
+
"session_file": _session_identifier(client, path.name),
|
|
471
|
+
"modified": modified.isoformat(),
|
|
472
|
+
"session_path": str(path),
|
|
473
|
+
})
|
|
474
|
+
transcript_candidates.sort(key=lambda row: row["modified"])
|
|
475
|
+
recent_sessions = [row for row in transcript_candidates if datetime.fromisoformat(row["modified"]) >= recent_start]
|
|
476
|
+
older_sessions = [row for row in transcript_candidates if row not in recent_sessions]
|
|
477
|
+
recent_session_quota = max(1, round(max_sessions * 0.7))
|
|
478
|
+
older_session_quota = max(0, max_sessions - recent_session_quota)
|
|
479
|
+
sampled_sessions = recent_sessions[-recent_session_quota:] + _sample_evenly(older_sessions, older_session_quota)
|
|
480
|
+
sampled_sessions.sort(key=lambda row: row["modified"])
|
|
481
|
+
|
|
482
|
+
stale_followups = safe_query(
|
|
483
|
+
NEXO_DB,
|
|
484
|
+
"SELECT id, description, date, status, created_at, updated_at FROM followups "
|
|
485
|
+
"WHERE status NOT IN ('COMPLETED', 'CANCELLED') ORDER BY date ASC, created_at ASC LIMIT 50"
|
|
486
|
+
)
|
|
487
|
+
older_than_week = []
|
|
488
|
+
week_ago = target_day - timedelta(days=7)
|
|
489
|
+
for row in stale_followups:
|
|
490
|
+
created = _parse_diary_created_at(row.get("created_at"))
|
|
491
|
+
if created and created < week_ago:
|
|
492
|
+
older_than_week.append({
|
|
493
|
+
"id": row.get("id", ""),
|
|
494
|
+
"description": str(row.get("description", "") or "")[:180],
|
|
495
|
+
"date": row.get("date", ""),
|
|
496
|
+
"status": row.get("status", ""),
|
|
497
|
+
"created_at": created.isoformat(),
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
return {
|
|
501
|
+
"horizon_days": horizon_days,
|
|
502
|
+
"recent_window_days": recent_days,
|
|
503
|
+
"sample_strategy": "70% recent + 30% older evenly sampled",
|
|
504
|
+
"historical_diaries": sampled_diaries,
|
|
505
|
+
"historical_sessions": sampled_sessions,
|
|
506
|
+
"historical_learnings": long_horizon_learnings,
|
|
507
|
+
"recurring_domains": recurring_domains.most_common(8),
|
|
508
|
+
"recurring_mental_states": recurring_states.most_common(8),
|
|
509
|
+
"recurring_self_critiques": recurring_critiques.most_common(6),
|
|
510
|
+
"stale_followups": older_than_week[:12],
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
|
|
347
514
|
# ── Discovery: scan NEXO_HOME for non-core content ───────────────────────
|
|
348
515
|
|
|
349
516
|
CORE_DIRS = {"data", "operations", "logs", "coordination", "brain"}
|
|
@@ -550,6 +717,14 @@ def main():
|
|
|
550
717
|
error_logs = collect_error_logs(target_date)
|
|
551
718
|
print(f" Log files with errors: {len(error_logs)}")
|
|
552
719
|
|
|
720
|
+
print("[collect] Building long-horizon context...")
|
|
721
|
+
long_horizon = collect_long_horizon_context(target_date)
|
|
722
|
+
print(
|
|
723
|
+
" Long horizon: "
|
|
724
|
+
f"{len(long_horizon.get('historical_diaries', []))} diary samples, "
|
|
725
|
+
f"{len(long_horizon.get('historical_sessions', []))} session samples"
|
|
726
|
+
)
|
|
727
|
+
|
|
553
728
|
# 5. Build per-session files + shared context
|
|
554
729
|
date_dir = DEEP_SLEEP_DIR / target_date
|
|
555
730
|
date_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -568,12 +743,17 @@ def main():
|
|
|
568
743
|
shared_parts.append(format_section("TRUST SCORE HISTORY (7d)", trust_history))
|
|
569
744
|
shared_parts.append(format_section("DISCOVERED NON-CORE CONTENT", extras))
|
|
570
745
|
shared_parts.append(format_section("ERROR LOGS", error_logs))
|
|
746
|
+
shared_parts.append(format_section("LONG-HORIZON CONTEXT (60d blend)", long_horizon))
|
|
571
747
|
|
|
572
748
|
shared_text = "\n".join(shared_parts)
|
|
573
749
|
shared_file = date_dir / "shared-context.txt"
|
|
574
750
|
shared_file.write_text(shared_text, encoding="utf-8")
|
|
575
751
|
print(f" Shared context: {len(shared_text) / 1024:.0f} KB")
|
|
576
752
|
|
|
753
|
+
long_horizon_file = date_dir / "long-horizon-context.json"
|
|
754
|
+
long_horizon_file.write_text(json.dumps(long_horizon, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
755
|
+
print(f" Long horizon JSON: {long_horizon_file.name}")
|
|
756
|
+
|
|
577
757
|
# Individual session files
|
|
578
758
|
session_files_written = []
|
|
579
759
|
session_txt_map = {}
|
|
@@ -654,6 +834,7 @@ def main():
|
|
|
654
834
|
"error_log_files": len(error_logs),
|
|
655
835
|
"date_dir": str(date_dir),
|
|
656
836
|
"shared_context_file": str(shared_file),
|
|
837
|
+
"long_horizon_file": str(long_horizon_file),
|
|
657
838
|
"context_file": str(legacy_file),
|
|
658
839
|
"total_size_bytes": total_size,
|
|
659
840
|
}
|
|
@@ -12,12 +12,17 @@ Read the extractions file provided below. It contains per-session findings inclu
|
|
|
12
12
|
|
|
13
13
|
Also read the runtime skill candidate file at `{{SKILL_RUNTIME_FILE}}`. It contains mature guide skills with repeated successful usage and candidates for automatic text→script evolution.
|
|
14
14
|
|
|
15
|
+
Also read the long-horizon file at `{{LONG_HORIZON_FILE}}`. It blends recent and older evidence from the last 60 days using a 70% recent / 30% older sample strategy. Use it to detect patterns that a single-day view would miss.
|
|
16
|
+
|
|
15
17
|
Synthesize across all sessions:
|
|
16
18
|
|
|
17
19
|
### 1. Cross-Session Patterns
|
|
18
20
|
- Same error appearing in multiple sessions (escalate confidence)
|
|
19
21
|
- Same protocol violation repeated (systemic issue)
|
|
20
22
|
- Related ideas mentioned across sessions (consolidate)
|
|
23
|
+
- Themes that recur across multiple weeks, not just today
|
|
24
|
+
- Cross-domain connections where an older learning or session sample explains a current issue
|
|
25
|
+
- Topics repeatedly mentioned over time but never formalized into a learning or followup
|
|
21
26
|
|
|
22
27
|
### 2. Morning Agenda
|
|
23
28
|
Generate a prioritized agenda for the next morning:
|
|
@@ -91,6 +91,7 @@ def main():
|
|
|
91
91
|
|
|
92
92
|
extractions_file = DEEP_SLEEP_DIR / f"{target_date}-extractions.json"
|
|
93
93
|
context_file = DEEP_SLEEP_DIR / f"{target_date}-context.txt"
|
|
94
|
+
long_horizon_file = DEEP_SLEEP_DIR / target_date / "long-horizon-context.json"
|
|
94
95
|
|
|
95
96
|
if not extractions_file.exists():
|
|
96
97
|
print(f"[synthesize] No extractions file for {target_date}. Run extract.py first.")
|
|
@@ -129,6 +130,7 @@ def main():
|
|
|
129
130
|
prompt = prompt_template.replace("{{EXTRACTIONS_FILE}}", str(extractions_file))
|
|
130
131
|
prompt = prompt.replace("{{CONTEXT_FILE}}", str(context_file))
|
|
131
132
|
prompt = prompt.replace("{{SKILL_RUNTIME_FILE}}", str(runtime_candidates_file))
|
|
133
|
+
prompt = prompt.replace("{{LONG_HORIZON_FILE}}", str(long_horizon_file))
|
|
132
134
|
|
|
133
135
|
print(f"[synthesize] Phase 3: Synthesizing {total_findings} findings from {target_date}")
|
|
134
136
|
print(f"[synthesize] Skill runtime candidates: {runtime_candidate_count}")
|
|
@@ -30,7 +30,7 @@ NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
|
|
|
30
30
|
DB="$NEXO_HOME/data/nexo.db"
|
|
31
31
|
[ -f "$DB" ] || exit 0
|
|
32
32
|
|
|
33
|
-
NEXO_SID=$(sqlite3 "$DB" "SELECT sid FROM sessions WHERE claude_session_id = '${CLAUDE_SID}' AND last_update_epoch > (strftime('%s','now') - 900) ORDER BY last_update_epoch DESC LIMIT 1;" 2>/dev/null)
|
|
33
|
+
NEXO_SID=$(sqlite3 "$DB" "SELECT sid FROM sessions WHERE (external_session_id = '${CLAUDE_SID}' OR claude_session_id = '${CLAUDE_SID}') AND last_update_epoch > (strftime('%s','now') - 900) ORDER BY last_update_epoch DESC LIMIT 1;" 2>/dev/null)
|
|
34
34
|
[ -z "$NEXO_SID" ] && exit 0
|
|
35
35
|
|
|
36
36
|
# 5. Check inbox — messages addressed to this session or broadcast
|
|
@@ -132,7 +132,7 @@ def analyze_entries(entries):
|
|
|
132
132
|
"files_modified": files_modified,
|
|
133
133
|
"self_critiques": self_critiques,
|
|
134
134
|
"entry_count": len(entries),
|
|
135
|
-
"
|
|
135
|
+
"source_counts": dict(Counter(e.get("source") or "unknown" for e in entries)),
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
|
|
@@ -188,7 +188,7 @@ def main():
|
|
|
188
188
|
# Filter out pure hook entries (tool captures)
|
|
189
189
|
session_entries = [
|
|
190
190
|
e for e in entries
|
|
191
|
-
if e.get("source") in ("claude", "hook-fallback")
|
|
191
|
+
if e.get("source") in ("claude", "hook-fallback", "codex", "codex_cli", "codex_cli_rs")
|
|
192
192
|
or "tasks" in e
|
|
193
193
|
]
|
|
194
194
|
|
|
@@ -210,7 +210,7 @@ def main():
|
|
|
210
210
|
reflection = {
|
|
211
211
|
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
|
212
212
|
"entries_processed": analysis["entry_count"],
|
|
213
|
-
"
|
|
213
|
+
"source_counts": analysis["source_counts"],
|
|
214
214
|
"tasks_seen": len(analysis["tasks"]),
|
|
215
215
|
"decisions_made": len(analysis["decisions"]),
|
|
216
216
|
"errors_resolved": len(analysis["errors"]),
|
|
@@ -243,8 +243,11 @@ def main():
|
|
|
243
243
|
except Exception as e:
|
|
244
244
|
print(f"Adaptive decay skipped: {e}")
|
|
245
245
|
|
|
246
|
+
source_summary = ", ".join(
|
|
247
|
+
f"{source}={count}" for source, count in sorted(analysis["source_counts"].items())
|
|
248
|
+
) or "none"
|
|
246
249
|
print(f"Reflection complete: {analysis['entry_count']} entries processed, "
|
|
247
|
-
f"{
|
|
250
|
+
f"sources: {source_summary}, "
|
|
248
251
|
f"{len(analysis['errors'])} errors, "
|
|
249
252
|
f"{len(analysis['self_critiques'])} critiques.")
|
|
250
253
|
|
package/src/server.py
CHANGED
|
@@ -200,7 +200,7 @@ mcp = FastMCP(
|
|
|
200
200
|
# ── Session management (3 tools) ──────────────────────────────────
|
|
201
201
|
|
|
202
202
|
@mcp.tool
|
|
203
|
-
def nexo_startup(task: str = "Startup", claude_session_id: str = "") -> str:
|
|
203
|
+
def nexo_startup(task: str = "Startup", claude_session_id: str = "", session_token: str = "", session_client: str = "") -> str:
|
|
204
204
|
"""Register new session, clean stale ones, return active sessions + alerts.
|
|
205
205
|
|
|
206
206
|
Call this ONCE at the start of every conversation.
|
|
@@ -208,11 +208,18 @@ def nexo_startup(task: str = "Startup", claude_session_id: str = "") -> str:
|
|
|
208
208
|
|
|
209
209
|
Args:
|
|
210
210
|
task: Initial task description.
|
|
211
|
-
claude_session_id:
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
211
|
+
claude_session_id: Legacy alias for the external client session token.
|
|
212
|
+
session_token: External client session token. Claude Code passes its UUID via hooks;
|
|
213
|
+
other clients may pass a synthetic durable token when useful.
|
|
214
|
+
Pass this to enable automatic inter-terminal inbox detection when available.
|
|
215
|
+
session_client: Optional client label such as `claude_code` or `codex`.
|
|
216
|
+
"""
|
|
217
|
+
return handle_startup(
|
|
218
|
+
task,
|
|
219
|
+
claude_session_id=claude_session_id,
|
|
220
|
+
session_token=session_token,
|
|
221
|
+
session_client=session_client,
|
|
222
|
+
)
|
|
216
223
|
|
|
217
224
|
|
|
218
225
|
@mcp.tool
|
package/src/tools_sessions.py
CHANGED
|
@@ -64,18 +64,35 @@ def _format_age(epoch: float) -> str:
|
|
|
64
64
|
return f"{int(seconds / 3600)}h{int((seconds % 3600) / 60)}m"
|
|
65
65
|
|
|
66
66
|
|
|
67
|
-
def handle_startup(
|
|
67
|
+
def handle_startup(
|
|
68
|
+
task: str = "Startup",
|
|
69
|
+
claude_session_id: str = "",
|
|
70
|
+
session_token: str = "",
|
|
71
|
+
session_client: str = "",
|
|
72
|
+
) -> str:
|
|
68
73
|
"""Full startup sequence: register, clean, report.
|
|
69
74
|
|
|
70
75
|
Args:
|
|
71
76
|
task: Initial task description
|
|
72
|
-
claude_session_id:
|
|
73
|
-
|
|
74
|
-
|
|
77
|
+
claude_session_id: Legacy alias for the external client session token.
|
|
78
|
+
session_token: External client session token. Claude Code passes its UUID via hooks;
|
|
79
|
+
other clients may pass a synthetic durable ID when useful.
|
|
80
|
+
Enables automatic inbox detection when hook-backed clients provide one.
|
|
81
|
+
session_client: Optional client label such as `claude_code` or `codex`.
|
|
75
82
|
"""
|
|
76
83
|
sid = _generate_sid()
|
|
77
84
|
cleaned = clean_stale_sessions()
|
|
78
|
-
|
|
85
|
+
linked_session_id = (session_token or claude_session_id or "").strip()
|
|
86
|
+
inferred_client = (session_client or "").strip()
|
|
87
|
+
if not inferred_client and claude_session_id and not session_token:
|
|
88
|
+
inferred_client = "claude_code"
|
|
89
|
+
register_session(
|
|
90
|
+
sid,
|
|
91
|
+
task,
|
|
92
|
+
claude_session_id=linked_session_id,
|
|
93
|
+
external_session_id=linked_session_id,
|
|
94
|
+
session_client=inferred_client,
|
|
95
|
+
)
|
|
79
96
|
_start_keepalive(sid)
|
|
80
97
|
active = get_active_sessions()
|
|
81
98
|
other_sessions = [s for s in active if s["sid"] != sid]
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- nexo-codex-agents-version: 1.
|
|
1
|
+
<!-- nexo-codex-agents-version: 1.1.0 -->
|
|
2
2
|
******CORE******
|
|
3
3
|
<!-- nexo:core:start -->
|
|
4
4
|
# {{NAME}} — NEXO Shared Brain for Codex
|
|
@@ -19,7 +19,7 @@ NEXO updates may rewrite `CORE`, but they must preserve `USER` verbatim.
|
|
|
19
19
|
|
|
20
20
|
## Codex Runtime Notes
|
|
21
21
|
- Codex does not provide Claude Code hooks. Compensate explicitly: startup, heartbeat on every user message, diary on clear end-of-session intent, and memory capture on every correction or durable decision.
|
|
22
|
-
- If a stable session token is useful,
|
|
22
|
+
- If a stable session token is useful, pass `session_token='codex-<task>-<date>'` and `session_client='codex'` to `nexo_startup`; otherwise leave them blank.
|
|
23
23
|
- `nexo chat` must inject this bootstrap explicitly when it launches Codex so the session starts as NEXO even if plain global Codex startup ignores global instructions.
|
|
24
24
|
- `nexo chat` should open the configured client and keep runtime/model alignment with NEXO preferences.
|
|
25
25
|
|