nexo-brain 7.23.13 → 7.25.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/.claude-plugin/plugin.json +1 -1
- package/README.md +15 -11
- package/bin/nexo-brain.js +42 -235
- package/package.json +1 -1
- package/src/auto_update.py +30 -0
- package/src/automation_supervisor.py +1 -1
- package/src/cli.py +255 -9
- package/src/cognitive_control_observatory.py +224 -0
- package/src/crons/manifest.json +13 -0
- package/src/dashboard/app.py +26 -9
- package/src/db/__init__.py +2 -0
- package/src/db/_fts.py +38 -8
- package/src/db/_learnings.py +1 -1
- package/src/db/_memory_v2.py +107 -1
- package/src/db/_protocol.py +2 -2
- package/src/db/_reminders.py +132 -4
- package/src/db/_schema.py +48 -2
- package/src/doctor/providers/runtime.py +69 -0
- package/src/events_bus.py +4 -5
- package/src/learning_resolver.py +419 -0
- package/src/lifecycle_events.py +9 -9
- package/src/local_context/api.py +67 -5
- package/src/local_context/usage_events.py +24 -0
- package/src/memory_fabric.py +536 -0
- package/src/memory_observation_processor.py +28 -0
- package/src/memory_retrieval.py +5 -5
- package/src/operator_language.py +2 -0
- package/src/plugins/backup.py +1 -1
- package/src/plugins/cortex.py +21 -21
- package/src/plugins/episodic_memory.py +11 -11
- package/src/plugins/goal_engine.py +3 -3
- package/src/plugins/personal_scripts.py +75 -0
- package/src/plugins/protocol.py +10 -1
- package/src/pre_answer_router.py +120 -3
- package/src/r_catalog.py +4 -5
- package/src/saved_not_used_audit.py +31 -31
- package/src/script_registry.py +444 -1
- package/src/scripts/deep-sleep/apply_findings.py +79 -17
- package/src/scripts/nexo-backup.sh +30 -0
- package/src/scripts/nexo-daily-self-audit.py +46 -13
- package/src/scripts/nexo-email-migrate-config.py +2 -2
- package/src/scripts/nexo-email-monitor.py +19 -19
- package/src/scripts/nexo-followup-hygiene.py +40 -8
- package/src/scripts/nexo-followup-runner.py +31 -31
- package/src/scripts/nexo-inbox-hook.sh +1 -1
- package/src/scripts/nexo-learning-validator.py +24 -3
- package/src/scripts/nexo-memory-fabric.py +45 -0
- package/src/server.py +73 -1
- package/src/system_catalog.py +31 -31
- package/src/tools_learnings.py +96 -65
- package/src/tools_memory_v2.py +2 -2
- package/src/tools_sessions.py +25 -7
- package/src/tools_transcripts.py +50 -8
- package/src/transcript_index.py +105 -2
- package/src/transcript_utils.py +65 -13
- package/templates/core-prompts/postmortem-consolidator.md +3 -3
- package/templates/core-prompts/r17-promise-debt-injection.md +1 -1
- package/templates/core-prompts/server-mcp-instructions.md +6 -6
- package/tool-enforcement-map.json +143 -13
package/src/db/__init__.py
CHANGED
|
@@ -100,6 +100,7 @@ from db._memory_v2 import (
|
|
|
100
100
|
list_memory_events,
|
|
101
101
|
memory_event_stats,
|
|
102
102
|
upsert_memory_observation,
|
|
103
|
+
publish_intraday_fact,
|
|
103
104
|
process_memory_observation_queue,
|
|
104
105
|
list_memory_observations,
|
|
105
106
|
search_memory_observations_fts,
|
|
@@ -124,6 +125,7 @@ from db._reminders import (
|
|
|
124
125
|
find_similar_followups,
|
|
125
126
|
compute_followup_impact, score_followup, score_active_followups,
|
|
126
127
|
add_item_history, get_item_history, validate_item_read_token,
|
|
128
|
+
normalize_followup_status, followup_due_state, followup_lifecycle_lane, followup_lifecycle_snapshot,
|
|
127
129
|
)
|
|
128
130
|
|
|
129
131
|
# Learnings
|
package/src/db/_fts.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""NEXO DB — Fts module."""
|
|
2
|
-
import os, pathlib, sqlite3, threading, datetime
|
|
2
|
+
import os, pathlib, re, sqlite3, threading, datetime
|
|
3
3
|
import paths
|
|
4
4
|
from db._core import get_db, now_epoch, DB_PATH
|
|
5
5
|
|
|
@@ -328,22 +328,26 @@ def fts_search(query: str, source_filter: str = None, limit: int = 20) -> list[d
|
|
|
328
328
|
limit: Max results (default 20)
|
|
329
329
|
"""
|
|
330
330
|
conn = get_db()
|
|
331
|
-
|
|
331
|
+
raw_query = query.strip()
|
|
332
|
+
words = raw_query.split()
|
|
332
333
|
if not words:
|
|
333
334
|
return []
|
|
334
335
|
|
|
335
336
|
# Expand with synonyms for cross-language matching
|
|
336
337
|
all_words = _expand_synonyms(words)
|
|
337
338
|
|
|
338
|
-
# Build FTS5 query: each word as quoted term with OR for broad matching
|
|
339
|
+
# Build FTS5 query: each word as quoted term with OR for broad matching.
|
|
340
|
+
# Symbol-heavy identifiers (emails, paths, refs) need deterministic token
|
|
341
|
+
# boundaries so FTS5 never treats punctuation as query syntax.
|
|
339
342
|
fts_terms = []
|
|
340
343
|
for w in all_words:
|
|
341
344
|
# Strip FTS5 special chars to avoid syntax errors
|
|
342
|
-
safe = w.replace('"', '').replace("'", '').replace('*', '').replace('^', '').
|
|
345
|
+
safe = w.replace('"', '').replace("'", '').replace('*', '').replace('^', '').strip()
|
|
346
|
+
safe = re.sub(r"[-@/\\:]+", " ", safe)
|
|
343
347
|
if not safe:
|
|
344
348
|
continue
|
|
345
|
-
# Split on dots (e.g.,
|
|
346
|
-
parts = [p.strip() for p in
|
|
349
|
+
# Split on dots and punctuation boundaries (e.g., emails, paths, files).
|
|
350
|
+
parts = [p.strip() for p in re.split(r"[.\s]+", safe) if p.strip()]
|
|
347
351
|
for part in parts:
|
|
348
352
|
fts_terms.append(f'"{part}"')
|
|
349
353
|
# Add prefix search for camelCase/code identifiers (contains uppercase mid-word)
|
|
@@ -361,6 +365,24 @@ def fts_search(query: str, source_filter: str = None, limit: int = 20) -> list[d
|
|
|
361
365
|
params.append(limit)
|
|
362
366
|
|
|
363
367
|
try:
|
|
368
|
+
exact_rows = []
|
|
369
|
+
if re.search(r"[@/\\:.-]", raw_query):
|
|
370
|
+
exact_where = ""
|
|
371
|
+
exact_params = [f"%{raw_query}%", f"%{raw_query}%", f"%{raw_query}%"]
|
|
372
|
+
if source_filter:
|
|
373
|
+
exact_where = "AND source = ?"
|
|
374
|
+
exact_params.append(source_filter)
|
|
375
|
+
exact_params.append(limit)
|
|
376
|
+
exact_rows = conn.execute(f"""
|
|
377
|
+
SELECT source, source_id, title,
|
|
378
|
+
substr(body, 1, 240) AS snippet,
|
|
379
|
+
category, updated_at, -100.0 AS rank
|
|
380
|
+
FROM unified_search
|
|
381
|
+
WHERE (title LIKE ? OR body LIKE ? OR source_id LIKE ?) {exact_where}
|
|
382
|
+
ORDER BY updated_at DESC
|
|
383
|
+
LIMIT ?
|
|
384
|
+
""", exact_params).fetchall()
|
|
385
|
+
|
|
364
386
|
rows = conn.execute(f"""
|
|
365
387
|
SELECT source, source_id, title,
|
|
366
388
|
snippet(unified_search, 3, '»', '«', '...', 40) AS snippet,
|
|
@@ -370,7 +392,16 @@ def fts_search(query: str, source_filter: str = None, limit: int = 20) -> list[d
|
|
|
370
392
|
ORDER BY rank
|
|
371
393
|
LIMIT ?
|
|
372
394
|
""", params).fetchall()
|
|
373
|
-
|
|
395
|
+
merged = []
|
|
396
|
+
seen = set()
|
|
397
|
+
for row in list(exact_rows) + list(rows):
|
|
398
|
+
item = dict(row)
|
|
399
|
+
key = (item.get("source"), item.get("source_id"))
|
|
400
|
+
if key in seen:
|
|
401
|
+
continue
|
|
402
|
+
seen.add(key)
|
|
403
|
+
merged.append(item)
|
|
404
|
+
return merged[:limit]
|
|
374
405
|
except Exception:
|
|
375
406
|
return []
|
|
376
407
|
|
|
@@ -403,4 +434,3 @@ def _migrate_add_index(conn, index_name: str, table: str, column: str):
|
|
|
403
434
|
"""Create index if it doesn't exist (idempotent)."""
|
|
404
435
|
conn.execute(f"CREATE INDEX IF NOT EXISTS {index_name} ON {table}({column})")
|
|
405
436
|
conn.commit()
|
|
406
|
-
|
package/src/db/_learnings.py
CHANGED
|
@@ -205,7 +205,7 @@ def find_similar_learnings(new_id: int, title: str, content: str, category: str)
|
|
|
205
205
|
return []
|
|
206
206
|
conn = _core().get_db()
|
|
207
207
|
rows = conn.execute(
|
|
208
|
-
"SELECT id, title, content FROM learnings WHERE category = ? AND id != ?",
|
|
208
|
+
"SELECT id, title, content FROM learnings WHERE category = ? AND id != ? AND COALESCE(status, 'active') = 'active'",
|
|
209
209
|
(category, new_id)
|
|
210
210
|
).fetchall()
|
|
211
211
|
results = []
|
package/src/db/_memory_v2.py
CHANGED
|
@@ -9,6 +9,7 @@ stable substrate without changing hook behaviour again.
|
|
|
9
9
|
import hashlib
|
|
10
10
|
import importlib
|
|
11
11
|
import json
|
|
12
|
+
import os
|
|
12
13
|
import re
|
|
13
14
|
import sqlite3
|
|
14
15
|
import sys
|
|
@@ -429,6 +430,101 @@ def _derive_observation(event: dict) -> dict:
|
|
|
429
430
|
}
|
|
430
431
|
|
|
431
432
|
|
|
433
|
+
def _intraday_facts_enabled() -> bool:
|
|
434
|
+
value = os.environ.get("NEXO_INTRADAY_FACTS_ENABLED", "1").strip().lower()
|
|
435
|
+
return value not in {"0", "false", "no", "off", "disabled"}
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _intraday_fact_candidate(observation: dict) -> bool:
|
|
439
|
+
if str(observation.get("status") or "active").lower() != "active":
|
|
440
|
+
return False
|
|
441
|
+
if float(observation.get("salience") or 0.0) < 0.62:
|
|
442
|
+
return False
|
|
443
|
+
if str(observation.get("observation_type") or "") not in {
|
|
444
|
+
"code_change",
|
|
445
|
+
"correction",
|
|
446
|
+
"decision",
|
|
447
|
+
"task_result",
|
|
448
|
+
}:
|
|
449
|
+
return False
|
|
450
|
+
if not str(observation.get("summary") or "").strip():
|
|
451
|
+
return False
|
|
452
|
+
|
|
453
|
+
facts = observation.get("facts") if isinstance(observation.get("facts"), dict) else {}
|
|
454
|
+
metadata = facts.get("metadata") if isinstance(facts.get("metadata"), dict) else {}
|
|
455
|
+
event_type = str(facts.get("event_type") or "")
|
|
456
|
+
source_type = str(facts.get("source_type") or "")
|
|
457
|
+
refs = [str(ref) for ref in observation.get("evidence_refs") or []]
|
|
458
|
+
observation_type = str(observation.get("observation_type") or "")
|
|
459
|
+
|
|
460
|
+
if observation_type == "task_result":
|
|
461
|
+
outcome = str(metadata.get("outcome") or event_type.removeprefix("protocol_task_") or "").lower()
|
|
462
|
+
return outcome in {"done", "closed", "completed", "success", "partial"}
|
|
463
|
+
if observation_type == "code_change":
|
|
464
|
+
verification_keys = {
|
|
465
|
+
"verified",
|
|
466
|
+
"verification",
|
|
467
|
+
"change_verify",
|
|
468
|
+
"test_output",
|
|
469
|
+
"tests_passed",
|
|
470
|
+
"evidence",
|
|
471
|
+
}
|
|
472
|
+
if source_type in {"change_log", "evidence_ledger", "protocol_task"}:
|
|
473
|
+
return True
|
|
474
|
+
if any(key in metadata and str(metadata.get(key) or "").strip() for key in verification_keys):
|
|
475
|
+
return True
|
|
476
|
+
return any(ref.startswith(("change_log:", "evidence:", "protocol_task:")) for ref in refs)
|
|
477
|
+
return observation_type in {"correction", "decision"}
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def publish_intraday_fact(observation: dict, *, ttl_hours: int = 36) -> dict:
|
|
481
|
+
"""Expose high-salience observations as temporary hot context.
|
|
482
|
+
|
|
483
|
+
This is deliberately not long-term promotion. Deep Sleep can later promote,
|
|
484
|
+
merge, or discard the observation; the intraday fact only keeps today's
|
|
485
|
+
important work visible while the operator keeps working.
|
|
486
|
+
"""
|
|
487
|
+
if not _intraday_facts_enabled():
|
|
488
|
+
return {"ok": True, "skipped": True, "reason": "intraday facts disabled"}
|
|
489
|
+
if not _intraday_fact_candidate(observation):
|
|
490
|
+
return {"ok": True, "skipped": True, "reason": "not an intraday fact candidate"}
|
|
491
|
+
|
|
492
|
+
uid = str(observation.get("observation_uid") or "").strip()
|
|
493
|
+
if not uid:
|
|
494
|
+
return {"ok": False, "error": "observation_uid is required"}
|
|
495
|
+
|
|
496
|
+
try:
|
|
497
|
+
from db._hot_context import capture_context_event
|
|
498
|
+
|
|
499
|
+
result = capture_context_event(
|
|
500
|
+
event_type="intraday_fact",
|
|
501
|
+
title=_truncate(observation.get("subject") or uid, 160),
|
|
502
|
+
summary=_truncate(observation.get("summary") or "", 600),
|
|
503
|
+
body=_truncate(observation.get("summary") or "", 1600),
|
|
504
|
+
context_key=f"intraday_fact:{uid}",
|
|
505
|
+
context_title=_truncate(observation.get("subject") or uid, 160),
|
|
506
|
+
context_summary=_truncate(observation.get("summary") or "", 600),
|
|
507
|
+
context_type="intraday_fact",
|
|
508
|
+
state="active",
|
|
509
|
+
owner="nexo",
|
|
510
|
+
actor="memory-observation-processor",
|
|
511
|
+
source_type="memory_observation",
|
|
512
|
+
source_id=uid,
|
|
513
|
+
session_id=str(observation.get("session_id") or ""),
|
|
514
|
+
metadata={
|
|
515
|
+
"observation_type": observation.get("observation_type") or "",
|
|
516
|
+
"project_key": observation.get("project_key") or "",
|
|
517
|
+
"promotion_state": observation.get("promotion_state") or "observation",
|
|
518
|
+
"evidence_refs": observation.get("evidence_refs") or [],
|
|
519
|
+
},
|
|
520
|
+
ttl_hours=ttl_hours,
|
|
521
|
+
created_at=float(observation.get("updated_at") or _core().now_epoch()),
|
|
522
|
+
)
|
|
523
|
+
return {"ok": True, "context_key": result.get("context_key"), "result": result}
|
|
524
|
+
except Exception as exc:
|
|
525
|
+
return {"ok": False, "error": _truncate(str(exc), 500)}
|
|
526
|
+
|
|
527
|
+
|
|
432
528
|
def upsert_memory_observation(observation: dict) -> dict:
|
|
433
529
|
conn = _core().get_db()
|
|
434
530
|
if not _table_exists(conn, "memory_observations"):
|
|
@@ -520,6 +616,7 @@ def process_memory_observation_queue(limit: int = 25) -> dict:
|
|
|
520
616
|
).fetchall()
|
|
521
617
|
processed = 0
|
|
522
618
|
failed = 0
|
|
619
|
+
intraday_facts = 0
|
|
523
620
|
now = _core().now_epoch()
|
|
524
621
|
for row in rows:
|
|
525
622
|
event = _row_to_event(row)
|
|
@@ -527,6 +624,9 @@ def process_memory_observation_queue(limit: int = 25) -> dict:
|
|
|
527
624
|
try:
|
|
528
625
|
observation = _derive_observation(event)
|
|
529
626
|
upsert_memory_observation(observation)
|
|
627
|
+
intraday_result = publish_intraday_fact(observation)
|
|
628
|
+
if intraday_result.get("ok") and not intraday_result.get("skipped"):
|
|
629
|
+
intraday_facts += 1
|
|
530
630
|
conn.execute(
|
|
531
631
|
"""
|
|
532
632
|
UPDATE memory_observation_queue
|
|
@@ -554,7 +654,13 @@ def process_memory_observation_queue(limit: int = 25) -> dict:
|
|
|
554
654
|
)
|
|
555
655
|
failed += 1
|
|
556
656
|
conn.commit()
|
|
557
|
-
return {
|
|
657
|
+
return {
|
|
658
|
+
"ok": failed == 0,
|
|
659
|
+
"processed": processed,
|
|
660
|
+
"failed": failed,
|
|
661
|
+
"intraday_facts": intraday_facts,
|
|
662
|
+
"total_seen": len(rows),
|
|
663
|
+
}
|
|
558
664
|
|
|
559
665
|
|
|
560
666
|
def list_memory_events(
|
package/src/db/_protocol.py
CHANGED
|
@@ -365,9 +365,9 @@ def cortex_evaluation_summary(days: int = 30) -> dict:
|
|
|
365
365
|
|
|
366
366
|
gaps: list[str] = []
|
|
367
367
|
if total < 3:
|
|
368
|
-
gaps.append("
|
|
368
|
+
gaps.append("Too few Cortex evaluations to infer stable improvement.")
|
|
369
369
|
if len(linked) < 2:
|
|
370
|
-
gaps.append("
|
|
370
|
+
gaps.append("Too few decisions linked to outcomes to measure real recommendation quality.")
|
|
371
371
|
|
|
372
372
|
return {
|
|
373
373
|
"days": max(1, int(days)),
|
package/src/db/_reminders.py
CHANGED
|
@@ -14,7 +14,53 @@ from db._hot_context import capture_context_event
|
|
|
14
14
|
from db._learnings import extract_keywords
|
|
15
15
|
from db._semantic_similarity import hybrid_similarity_score
|
|
16
16
|
|
|
17
|
-
ACTIVE_EXCLUDED_STATUSES = {
|
|
17
|
+
ACTIVE_EXCLUDED_STATUSES = {
|
|
18
|
+
"ARCHIVED",
|
|
19
|
+
"BLOCKED",
|
|
20
|
+
"DELETED",
|
|
21
|
+
"DONE",
|
|
22
|
+
"EXPIRED",
|
|
23
|
+
"NEEDS_DECISION",
|
|
24
|
+
"PARKED",
|
|
25
|
+
"STALE_REVIEW",
|
|
26
|
+
"WAITING",
|
|
27
|
+
"WAITING_EXTERNAL",
|
|
28
|
+
"WAITING_USER",
|
|
29
|
+
}
|
|
30
|
+
FOLLOWUP_TERMINAL_STATUSES = {"COMPLETED", "DELETED", "DONE", "EXPIRED", "ARCHIVED"}
|
|
31
|
+
FOLLOWUP_WAITING_USER_STATUSES = {"NEEDS_DECISION", "WAITING_USER"}
|
|
32
|
+
FOLLOWUP_WAITING_EXTERNAL_STATUSES = {"WAITING", "WAITING_EXTERNAL"}
|
|
33
|
+
FOLLOWUP_BLOCKED_STATUSES = {"BLOCKED"}
|
|
34
|
+
FOLLOWUP_STATUS_ALIASES = {
|
|
35
|
+
"": "PENDING",
|
|
36
|
+
"PENDIENTE": "PENDING",
|
|
37
|
+
"PENDING": "PENDING",
|
|
38
|
+
"ACTIVE": "PENDING",
|
|
39
|
+
"ACTIVO": "PENDING",
|
|
40
|
+
"COMPLETADO": "COMPLETED",
|
|
41
|
+
"COMPLETED": "COMPLETED",
|
|
42
|
+
"DONE": "DONE",
|
|
43
|
+
"HECHO": "DONE",
|
|
44
|
+
"ELIMINADO": "DELETED",
|
|
45
|
+
"DELETED": "DELETED",
|
|
46
|
+
"ARCHIVED": "ARCHIVED",
|
|
47
|
+
"ARCHIVADO": "ARCHIVED",
|
|
48
|
+
"BLOCKED": "BLOCKED",
|
|
49
|
+
"BLOQUEADO": "BLOCKED",
|
|
50
|
+
"WAITING": "WAITING",
|
|
51
|
+
"WAITING_EXTERNAL": "WAITING_EXTERNAL",
|
|
52
|
+
"ESPERANDO": "WAITING_EXTERNAL",
|
|
53
|
+
"WAITING_USER": "WAITING_USER",
|
|
54
|
+
"NEEDS_DECISION": "NEEDS_DECISION",
|
|
55
|
+
"NEEDS-DECISION": "NEEDS_DECISION",
|
|
56
|
+
"NEEDS DECISION": "NEEDS_DECISION",
|
|
57
|
+
"DECISION": "NEEDS_DECISION",
|
|
58
|
+
"NECESITA_DECISION": "NEEDS_DECISION",
|
|
59
|
+
"PARKED": "PARKED",
|
|
60
|
+
"APARCADO": "PARKED",
|
|
61
|
+
"STALE_REVIEW": "STALE_REVIEW",
|
|
62
|
+
"EXPIRED": "EXPIRED",
|
|
63
|
+
}
|
|
18
64
|
READ_TOKEN_TTL_SECONDS = 30 * 60
|
|
19
65
|
|
|
20
66
|
# Opportunistic cleanup of expired item_read_tokens: runs at most once every
|
|
@@ -71,6 +117,49 @@ def _truncate(text: str | None, limit: int = 240) -> str:
|
|
|
71
117
|
return text if len(text) <= limit else text[: limit - 3] + "..."
|
|
72
118
|
|
|
73
119
|
|
|
120
|
+
def normalize_followup_status(status: str | None) -> str:
|
|
121
|
+
"""Return the canonical followup status used by lifecycle tools."""
|
|
122
|
+
clean = str(status or "").strip()
|
|
123
|
+
if not clean:
|
|
124
|
+
return "PENDING"
|
|
125
|
+
upper = clean.upper().replace("-", "_")
|
|
126
|
+
if upper.startswith("COMPLETED"):
|
|
127
|
+
return "COMPLETED"
|
|
128
|
+
return FOLLOWUP_STATUS_ALIASES.get(upper, upper)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def followup_lifecycle_lane(followup: dict) -> str:
|
|
132
|
+
"""Classify one followup into an operational lane without mutating it."""
|
|
133
|
+
status = normalize_followup_status(followup.get("status"))
|
|
134
|
+
owner = str(followup.get("owner") or "").strip().lower()
|
|
135
|
+
if status in FOLLOWUP_TERMINAL_STATUSES:
|
|
136
|
+
return "completed" if status in {"COMPLETED", "DONE"} else status.lower()
|
|
137
|
+
if status in FOLLOWUP_BLOCKED_STATUSES:
|
|
138
|
+
return "blocked"
|
|
139
|
+
if status in FOLLOWUP_WAITING_USER_STATUSES or owner == "user":
|
|
140
|
+
return "waiting_user"
|
|
141
|
+
if status in FOLLOWUP_WAITING_EXTERNAL_STATUSES or owner == "waiting":
|
|
142
|
+
return "waiting_external"
|
|
143
|
+
if status == "PARKED":
|
|
144
|
+
return "parked"
|
|
145
|
+
if status == "STALE_REVIEW":
|
|
146
|
+
return "stale_review"
|
|
147
|
+
return "active"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def followup_due_state(followup: dict) -> str:
|
|
151
|
+
"""Return due/backlog/future/completed without changing the lifecycle lane."""
|
|
152
|
+
lane = followup_lifecycle_lane(followup)
|
|
153
|
+
if lane != "active":
|
|
154
|
+
return lane
|
|
155
|
+
due = _parse_date(followup.get("date"))
|
|
156
|
+
if due is None:
|
|
157
|
+
return "backlog"
|
|
158
|
+
if due > datetime.date.today():
|
|
159
|
+
return "future"
|
|
160
|
+
return "due"
|
|
161
|
+
|
|
162
|
+
|
|
74
163
|
def _format_changes(before: sqlite3.Row | dict | None, after: sqlite3.Row | dict | None, fields: list[str]) -> str:
|
|
75
164
|
if before is None or after is None:
|
|
76
165
|
return ""
|
|
@@ -226,18 +315,20 @@ def _active_status_where(column_name: str = "status") -> str:
|
|
|
226
315
|
excluded = ", ".join(f"'{value}'" for value in sorted(ACTIVE_EXCLUDED_STATUSES))
|
|
227
316
|
return (
|
|
228
317
|
f"{column_name} NOT LIKE 'COMPLETED%' "
|
|
229
|
-
f"AND {column_name} NOT IN ({excluded})"
|
|
318
|
+
f"AND UPPER(COALESCE({column_name}, '')) NOT IN ({excluded})"
|
|
230
319
|
)
|
|
231
320
|
|
|
232
321
|
|
|
233
322
|
def _context_state_from_status(status: str | None) -> str:
|
|
234
|
-
normalized =
|
|
323
|
+
normalized = normalize_followup_status(status)
|
|
235
324
|
if normalized.startswith("COMPLETED"):
|
|
236
325
|
return "resolved"
|
|
237
326
|
if normalized == "DELETED":
|
|
238
327
|
return "abandoned"
|
|
239
|
-
if normalized
|
|
328
|
+
if normalized in {"WAITING_USER", "NEEDS_DECISION"}:
|
|
240
329
|
return "waiting_user"
|
|
330
|
+
if normalized in {"WAITING", "WAITING_EXTERNAL"}:
|
|
331
|
+
return "waiting_third_party"
|
|
241
332
|
if normalized == "BLOCKED":
|
|
242
333
|
return "blocked"
|
|
243
334
|
return "active"
|
|
@@ -632,6 +723,7 @@ def create_followup(
|
|
|
632
723
|
"""
|
|
633
724
|
conn = get_db()
|
|
634
725
|
now = now_epoch()
|
|
726
|
+
status = normalize_followup_status(status)
|
|
635
727
|
similar = find_similar_followups(description)
|
|
636
728
|
warning = ""
|
|
637
729
|
if similar:
|
|
@@ -742,6 +834,8 @@ def update_followup(
|
|
|
742
834
|
updates.pop("owner")
|
|
743
835
|
else:
|
|
744
836
|
updates["owner"] = coerced
|
|
837
|
+
if "status" in updates:
|
|
838
|
+
updates["status"] = normalize_followup_status(updates["status"])
|
|
745
839
|
if not updates:
|
|
746
840
|
return {"error": "No valid fields to update"}
|
|
747
841
|
|
|
@@ -1102,6 +1196,40 @@ def get_followup_history(id: str, limit: int = 20) -> list[dict]:
|
|
|
1102
1196
|
return get_item_history("followup", id, limit=limit)
|
|
1103
1197
|
|
|
1104
1198
|
|
|
1199
|
+
def followup_lifecycle_snapshot(limit: int = 500) -> dict:
|
|
1200
|
+
"""Return followups grouped by lifecycle lane for dashboards and runners."""
|
|
1201
|
+
conn = get_db()
|
|
1202
|
+
rows = conn.execute(
|
|
1203
|
+
"SELECT * FROM followups ORDER BY updated_at DESC LIMIT ?",
|
|
1204
|
+
(max(1, min(int(limit or 500), 5000)),),
|
|
1205
|
+
).fetchall()
|
|
1206
|
+
lanes = {
|
|
1207
|
+
"active": [],
|
|
1208
|
+
"waiting_user": [],
|
|
1209
|
+
"waiting_external": [],
|
|
1210
|
+
"blocked": [],
|
|
1211
|
+
"parked": [],
|
|
1212
|
+
"stale_review": [],
|
|
1213
|
+
"expired": [],
|
|
1214
|
+
"completed": [],
|
|
1215
|
+
"deleted": [],
|
|
1216
|
+
"archived": [],
|
|
1217
|
+
}
|
|
1218
|
+
for row in rows:
|
|
1219
|
+
item = dict(row)
|
|
1220
|
+
item["status"] = normalize_followup_status(item.get("status"))
|
|
1221
|
+
lane = followup_lifecycle_lane(item)
|
|
1222
|
+
item["lifecycle_lane"] = lane
|
|
1223
|
+
item["due_state"] = followup_due_state(item)
|
|
1224
|
+
lanes.setdefault(lane, []).append(item)
|
|
1225
|
+
return {
|
|
1226
|
+
"ok": True,
|
|
1227
|
+
"total": len(rows),
|
|
1228
|
+
"lanes": lanes,
|
|
1229
|
+
"counts": {lane: len(items) for lane, items in lanes.items()},
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
|
|
1105
1233
|
def _parse_date(date_str: str | None) -> datetime.date | None:
|
|
1106
1234
|
text = str(date_str or "").strip()
|
|
1107
1235
|
if not text:
|
package/src/db/_schema.py
CHANGED
|
@@ -1341,7 +1341,7 @@ def _m49_protocol_guard_ack_backfill(conn):
|
|
|
1341
1341
|
|
|
1342
1342
|
def _m50_dedupe_nexo_product_learning_pair(conn):
|
|
1343
1343
|
"""Block D.2 / G7-adjacent: dedupe the two learnings that encode the
|
|
1344
|
-
"NEXO Brain
|
|
1344
|
+
"NEXO Brain public product vs Francisco's personal instance"
|
|
1345
1345
|
invariant as a physically separate pair.
|
|
1346
1346
|
|
|
1347
1347
|
Francisco's runtime has this concept stored twice (historical IDs 212
|
|
@@ -1368,7 +1368,7 @@ def _m50_dedupe_nexo_product_learning_pair(conn):
|
|
|
1368
1368
|
|
|
1369
1369
|
def _norm(text: str) -> str:
|
|
1370
1370
|
# Collapse whitespace and strip punctuation/case so "NEXO Brain
|
|
1371
|
-
#
|
|
1371
|
+
# public product vs personal instance" matches its twin no
|
|
1372
1372
|
# matter how the operator rephrased it.
|
|
1373
1373
|
import re as _re
|
|
1374
1374
|
stripped = _re.sub(r"[\W_]+", " ", str(text or "")).strip().lower()
|
|
@@ -2080,6 +2080,51 @@ def _m67_diary_quality_backfill_repair(conn):
|
|
|
2080
2080
|
_migrate_add_index(conn, "idx_diary_archive_quality", "diary_archive", "quality_tier, quality_score, created_at")
|
|
2081
2081
|
|
|
2082
2082
|
|
|
2083
|
+
def _m68_memory_fabric_index(conn):
|
|
2084
|
+
"""Memory Fabric v1 index tables for historical backup memory."""
|
|
2085
|
+
conn.executescript(
|
|
2086
|
+
"""
|
|
2087
|
+
CREATE TABLE IF NOT EXISTS memory_fabric_sources (
|
|
2088
|
+
source_id TEXT PRIMARY KEY,
|
|
2089
|
+
source_type TEXT NOT NULL,
|
|
2090
|
+
source_ref TEXT NOT NULL,
|
|
2091
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
2092
|
+
item_count INTEGER NOT NULL DEFAULT 0,
|
|
2093
|
+
last_indexed_at TEXT DEFAULT '',
|
|
2094
|
+
metadata_json TEXT NOT NULL DEFAULT '{}'
|
|
2095
|
+
);
|
|
2096
|
+
|
|
2097
|
+
CREATE TABLE IF NOT EXISTS historical_diary_index (
|
|
2098
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2099
|
+
source_backup_path TEXT NOT NULL,
|
|
2100
|
+
source_table TEXT NOT NULL DEFAULT 'session_diary',
|
|
2101
|
+
source_row_id INTEGER NOT NULL,
|
|
2102
|
+
session_id TEXT NOT NULL DEFAULT '',
|
|
2103
|
+
created_at TEXT NOT NULL DEFAULT '',
|
|
2104
|
+
domain TEXT NOT NULL DEFAULT '',
|
|
2105
|
+
summary TEXT NOT NULL DEFAULT '',
|
|
2106
|
+
decisions TEXT NOT NULL DEFAULT '',
|
|
2107
|
+
pending TEXT NOT NULL DEFAULT '',
|
|
2108
|
+
context_next TEXT NOT NULL DEFAULT '',
|
|
2109
|
+
mental_state TEXT NOT NULL DEFAULT '',
|
|
2110
|
+
self_critique TEXT NOT NULL DEFAULT '',
|
|
2111
|
+
source TEXT NOT NULL DEFAULT '',
|
|
2112
|
+
content_hash TEXT NOT NULL UNIQUE,
|
|
2113
|
+
indexed_at TEXT DEFAULT (datetime('now')),
|
|
2114
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
2115
|
+
UNIQUE(source_backup_path, source_table, source_row_id)
|
|
2116
|
+
);
|
|
2117
|
+
|
|
2118
|
+
CREATE INDEX IF NOT EXISTS idx_historical_diary_session
|
|
2119
|
+
ON historical_diary_index(session_id);
|
|
2120
|
+
CREATE INDEX IF NOT EXISTS idx_historical_diary_created
|
|
2121
|
+
ON historical_diary_index(created_at);
|
|
2122
|
+
CREATE INDEX IF NOT EXISTS idx_historical_diary_domain
|
|
2123
|
+
ON historical_diary_index(domain);
|
|
2124
|
+
"""
|
|
2125
|
+
)
|
|
2126
|
+
|
|
2127
|
+
|
|
2083
2128
|
MIGRATIONS = [
|
|
2084
2129
|
(1, "learnings_columns", _m1_learnings_columns),
|
|
2085
2130
|
(2, "followups_reasoning", _m2_followups_reasoning),
|
|
@@ -2148,6 +2193,7 @@ MIGRATIONS = [
|
|
|
2148
2193
|
(65, "diary_quality", _m65_diary_quality),
|
|
2149
2194
|
(66, "transcript_index", _m66_transcript_index),
|
|
2150
2195
|
(67, "diary_quality_backfill_repair", _m67_diary_quality_backfill_repair),
|
|
2196
|
+
(68, "memory_fabric_index", _m68_memory_fabric_index),
|
|
2151
2197
|
]
|
|
2152
2198
|
|
|
2153
2199
|
|
|
@@ -3900,6 +3900,74 @@ def check_local_index_hygiene(fix: bool = False) -> DoctorCheck:
|
|
|
3900
3900
|
)
|
|
3901
3901
|
|
|
3902
3902
|
|
|
3903
|
+
def check_memory_fabric_health(fix: bool = False) -> DoctorCheck:
|
|
3904
|
+
try:
|
|
3905
|
+
import memory_fabric
|
|
3906
|
+
|
|
3907
|
+
repair = None
|
|
3908
|
+
if fix:
|
|
3909
|
+
repair = memory_fabric.repair_memory_fabric(
|
|
3910
|
+
transcript_hours=720,
|
|
3911
|
+
transcript_limit=1000,
|
|
3912
|
+
backup_limit=5000,
|
|
3913
|
+
)
|
|
3914
|
+
report = memory_fabric.memory_fabric_health(include_backup_scan=True)
|
|
3915
|
+
issues = report.get("issues") or []
|
|
3916
|
+
evidence = [
|
|
3917
|
+
"transcripts=" + json.dumps(report.get("transcripts") or {}, sort_keys=True),
|
|
3918
|
+
"historical_diaries=" + json.dumps(report.get("historical_diaries") or {}, sort_keys=True),
|
|
3919
|
+
"local_context=" + json.dumps(report.get("local_context") or {}, sort_keys=True),
|
|
3920
|
+
"knowledge_graph=" + json.dumps(report.get("knowledge_graph") or {}, sort_keys=True),
|
|
3921
|
+
]
|
|
3922
|
+
evidence.extend(
|
|
3923
|
+
f"issue={item.get('severity')}:{item.get('code')}:{item.get('message')}"
|
|
3924
|
+
for item in issues[:6]
|
|
3925
|
+
if isinstance(item, dict)
|
|
3926
|
+
)
|
|
3927
|
+
if repair:
|
|
3928
|
+
evidence.append("repair=" + json.dumps({
|
|
3929
|
+
"transcripts_indexed": (repair.get("transcripts") or {}).get("indexed"),
|
|
3930
|
+
"historical_diaries_inserted": (repair.get("backups") or {}).get("inserted"),
|
|
3931
|
+
}, sort_keys=True))
|
|
3932
|
+
blocking = [
|
|
3933
|
+
item for item in issues
|
|
3934
|
+
if isinstance(item, dict) and item.get("code") in {"transcript_index_empty", "backup_diaries_not_reconciled"}
|
|
3935
|
+
]
|
|
3936
|
+
if not blocking:
|
|
3937
|
+
return DoctorCheck(
|
|
3938
|
+
id="runtime.memory_fabric",
|
|
3939
|
+
tier="runtime",
|
|
3940
|
+
status="healthy",
|
|
3941
|
+
severity="info",
|
|
3942
|
+
summary="Memory Fabric coverage is queryable",
|
|
3943
|
+
evidence=evidence,
|
|
3944
|
+
repair_plan=[],
|
|
3945
|
+
fixed=bool(repair),
|
|
3946
|
+
)
|
|
3947
|
+
return DoctorCheck(
|
|
3948
|
+
id="runtime.memory_fabric",
|
|
3949
|
+
tier="runtime",
|
|
3950
|
+
status="degraded",
|
|
3951
|
+
severity="warn",
|
|
3952
|
+
summary="Memory Fabric coverage needs repair",
|
|
3953
|
+
evidence=evidence,
|
|
3954
|
+
repair_plan=["Run `nexo doctor --tier runtime --fix` or `nexo update` to warm transcript and historical backup indexes"],
|
|
3955
|
+
escalation_prompt="Some memory sources exist outside the active query indexes, so exact historical lookup may fall back to slow raw scans.",
|
|
3956
|
+
fixed=bool(repair),
|
|
3957
|
+
)
|
|
3958
|
+
except Exception as exc:
|
|
3959
|
+
return DoctorCheck(
|
|
3960
|
+
id="runtime.memory_fabric",
|
|
3961
|
+
tier="runtime",
|
|
3962
|
+
status="degraded",
|
|
3963
|
+
severity="warn",
|
|
3964
|
+
summary="Memory Fabric health could not be checked",
|
|
3965
|
+
evidence=[str(exc)],
|
|
3966
|
+
repair_plan=["Inspect memory_fabric.py and DB migrations"],
|
|
3967
|
+
escalation_prompt="Support cannot verify unified memory coverage.",
|
|
3968
|
+
)
|
|
3969
|
+
|
|
3970
|
+
|
|
3903
3971
|
def run_runtime_checks(fix: bool = False) -> list[DoctorCheck]:
|
|
3904
3972
|
"""Run all runtime-tier checks. Read-only by default."""
|
|
3905
3973
|
return [
|
|
@@ -3922,6 +3990,7 @@ def run_runtime_checks(fix: bool = False) -> list[DoctorCheck]:
|
|
|
3922
3990
|
safe_check(check_automation_caller_coverage),
|
|
3923
3991
|
safe_check(check_state_watchers),
|
|
3924
3992
|
safe_check(check_local_index_hygiene, fix=fix),
|
|
3993
|
+
safe_check(check_memory_fabric_health, fix=fix),
|
|
3925
3994
|
safe_check(check_release_artifact_sync),
|
|
3926
3995
|
safe_check(check_release_trace_hygiene),
|
|
3927
3996
|
safe_check(check_launchagent_inventory),
|
package/src/events_bus.py
CHANGED
|
@@ -101,11 +101,10 @@ def emit(
|
|
|
101
101
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
102
102
|
_rotate_if_needed(path)
|
|
103
103
|
|
|
104
|
-
# v0.32.5 —
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
# `_next_id` se llama DENTRO del lock, garantizando monotonía.
|
|
104
|
+
# v0.32.5 — `_next_id(path)` used to run BEFORE taking the flock. Two
|
|
105
|
+
# concurrent emitters could read the same tail, compute the same id, and
|
|
106
|
+
# write two events with that id. Renderer dedup then dropped the second
|
|
107
|
+
# event silently. Calling `_next_id` INSIDE the lock guarantees monotonicity.
|
|
109
108
|
line = None
|
|
110
109
|
event = None
|
|
111
110
|
with path.open("a", encoding="utf-8") as fh:
|