nexo-brain 2.6.16 → 2.6.18

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.
@@ -19,6 +19,7 @@ import json
19
19
  import os
20
20
  import sqlite3
21
21
  import sys
22
+ from collections import Counter
22
23
  from datetime import datetime, timedelta
23
24
  from pathlib import Path
24
25
 
@@ -309,6 +310,392 @@ def create_abandoned_followups(synthesis: dict) -> list[dict]:
309
310
  return results
310
311
 
311
312
 
313
+ def _safe_query(db_path: Path, query: str, params: tuple = ()) -> list[dict]:
314
+ if not db_path.exists():
315
+ return []
316
+ try:
317
+ conn = sqlite3.connect(str(db_path))
318
+ conn.row_factory = sqlite3.Row
319
+ rows = conn.execute(query, params).fetchall()
320
+ conn.close()
321
+ return [dict(row) for row in rows]
322
+ except Exception:
323
+ return []
324
+
325
+
326
+ def _parse_any_datetime(value) -> datetime | None:
327
+ if value in (None, ""):
328
+ return None
329
+ try:
330
+ if isinstance(value, (int, float)) or (isinstance(value, str) and str(value).strip().isdigit()):
331
+ return datetime.fromtimestamp(float(value))
332
+ except Exception:
333
+ return None
334
+ raw = str(value).strip()
335
+ for fmt in ("%Y-%m-%d", "%Y-%m-%d %H:%M:%S"):
336
+ try:
337
+ return datetime.strptime(raw[:19], fmt)
338
+ except Exception:
339
+ continue
340
+ try:
341
+ return datetime.fromisoformat(raw.replace("Z", "+00:00").replace("+00:00", ""))
342
+ except Exception:
343
+ return None
344
+
345
+
346
+ def _load_project_aliases() -> dict[str, set[str]]:
347
+ atlas_path = NEXO_HOME / "brain" / "project-atlas.json"
348
+ if not atlas_path.is_file():
349
+ return {}
350
+ try:
351
+ payload = json.loads(atlas_path.read_text())
352
+ except Exception:
353
+ return {}
354
+ if not isinstance(payload, dict):
355
+ return {}
356
+ aliases: dict[str, set[str]] = {}
357
+ for key, value in payload.items():
358
+ if str(key).startswith("_"):
359
+ continue
360
+ canonical = str(key).strip().lower()
361
+ alias_set = {canonical, canonical.replace("-", " "), canonical.replace("_", " ")}
362
+ if isinstance(value, dict):
363
+ for alias in value.get("aliases", []) or []:
364
+ alias_value = str(alias or "").strip().lower()
365
+ if alias_value:
366
+ alias_set.add(alias_value)
367
+ alias_set.add(alias_value.replace("-", " "))
368
+ aliases[canonical] = {item for item in alias_set if item}
369
+ return aliases
370
+
371
+
372
+ def _match_projects(text: str, alias_map: dict[str, set[str]]) -> set[str]:
373
+ haystack = str(text or "").strip().lower()
374
+ if not haystack:
375
+ return set()
376
+ matches: set[str] = set()
377
+ for canonical, aliases in alias_map.items():
378
+ for alias in sorted(aliases, key=len, reverse=True):
379
+ if alias and alias in haystack:
380
+ matches.add(canonical)
381
+ break
382
+ return matches
383
+
384
+
385
+ def _priority_weight(value) -> float:
386
+ lowered = str(value or "").strip().lower()
387
+ if lowered in {"critical", "urgent"}:
388
+ return 4.0
389
+ if lowered == "high":
390
+ return 3.0
391
+ if lowered == "medium":
392
+ return 2.0
393
+ if lowered == "low":
394
+ return 1.0
395
+ return 1.5
396
+
397
+
398
+ def _project_weighting_window(target_date: str, *, window_days: int) -> list[dict]:
399
+ target_day = datetime.strptime(target_date, "%Y-%m-%d")
400
+ window_start = target_day - timedelta(days=max(0, window_days - 1))
401
+ alias_map = _load_project_aliases()
402
+ scoreboard: dict[str, dict] = {}
403
+
404
+ def normalize_project(project: str) -> str:
405
+ lowered = str(project or "").strip().lower()
406
+ if not lowered:
407
+ return ""
408
+ matched = _match_projects(lowered, alias_map)
409
+ if matched:
410
+ return sorted(matched)[0]
411
+ return lowered
412
+
413
+ def bump(project: str, score: float, signal_key: str, reason: str) -> None:
414
+ canonical = normalize_project(project)
415
+ if not canonical:
416
+ return
417
+ slot = scoreboard.setdefault(
418
+ canonical,
419
+ {
420
+ "project": canonical,
421
+ "score": 0.0,
422
+ "signals": {
423
+ "diary_sessions": 0,
424
+ "learnings": 0,
425
+ "followups": 0,
426
+ "decisions": 0,
427
+ },
428
+ "reasons": [],
429
+ },
430
+ )
431
+ slot["score"] += score
432
+ slot["signals"][signal_key] += 1
433
+ if reason and reason not in slot["reasons"]:
434
+ slot["reasons"].append(reason)
435
+
436
+ diary_rows = _safe_query(
437
+ NEXO_DB,
438
+ "SELECT created_at, summary, self_critique, domain FROM session_diary ORDER BY created_at DESC",
439
+ )
440
+ for row in diary_rows:
441
+ created = _parse_any_datetime(row.get("created_at"))
442
+ if not created or created < window_start or created > target_day + timedelta(days=1):
443
+ continue
444
+ recency_bonus = 1.4 if (target_day - created).days <= 7 else 1.0
445
+ matched = _match_projects(
446
+ " ".join(
447
+ [
448
+ str(row.get("summary", "") or ""),
449
+ str(row.get("self_critique", "") or ""),
450
+ ]
451
+ ),
452
+ alias_map,
453
+ )
454
+ domain = normalize_project(str(row.get("domain", "") or ""))
455
+ if domain:
456
+ matched.add(domain)
457
+ for project in matched:
458
+ bump(project, 3.0 * recency_bonus, "diary_sessions", "recent diary activity")
459
+
460
+ learning_rows = _safe_query(
461
+ NEXO_DB,
462
+ "SELECT title, content, applies_to, priority, weight, updated_at, created_at FROM learnings "
463
+ "ORDER BY COALESCE(updated_at, created_at) DESC LIMIT 180",
464
+ )
465
+ for row in learning_rows:
466
+ when = _parse_any_datetime(row.get("updated_at") or row.get("created_at"))
467
+ if when and when < window_start:
468
+ continue
469
+ matched = _match_projects(
470
+ " ".join(
471
+ [
472
+ str(row.get("applies_to", "") or ""),
473
+ str(row.get("title", "") or ""),
474
+ str(row.get("content", "") or ""),
475
+ ]
476
+ ),
477
+ alias_map,
478
+ )
479
+ if not matched:
480
+ continue
481
+ score = 1.0 + _priority_weight(row.get("priority")) + min(2.0, max(0.0, float(row.get("weight", 0) or 0)))
482
+ for project in matched:
483
+ bump(project, score, "learnings", "recent leverage-bearing learning")
484
+
485
+ followup_rows = _safe_query(
486
+ NEXO_DB,
487
+ "SELECT description, date, status, priority, created_at, updated_at, reasoning FROM followups "
488
+ "WHERE status NOT IN ('COMPLETED', 'CANCELLED') ORDER BY date ASC, created_at ASC LIMIT 160",
489
+ )
490
+ for row in followup_rows:
491
+ matched = _match_projects(
492
+ " ".join(
493
+ [
494
+ str(row.get("description", "") or ""),
495
+ str(row.get("reasoning", "") or ""),
496
+ ]
497
+ ),
498
+ alias_map,
499
+ )
500
+ if not matched:
501
+ continue
502
+ overdue_bonus = 0.0
503
+ due_dt = _parse_any_datetime(row.get("date"))
504
+ if due_dt and due_dt <= target_day:
505
+ overdue_bonus = 1.5
506
+ score = 1.5 + _priority_weight(row.get("priority")) + overdue_bonus
507
+ for project in matched:
508
+ bump(project, score, "followups", "open followup pressure")
509
+
510
+ decision_rows = _safe_query(
511
+ NEXO_DB,
512
+ "SELECT domain, outcome, status, reasoning, created_at, review_due_at FROM decisions "
513
+ "ORDER BY COALESCE(created_at, review_due_at) DESC LIMIT 160",
514
+ )
515
+ for row in decision_rows:
516
+ when = _parse_any_datetime(row.get("created_at") or row.get("review_due_at"))
517
+ if when and when < window_start:
518
+ continue
519
+ matched = _match_projects(
520
+ " ".join(
521
+ [
522
+ str(row.get("reasoning", "") or ""),
523
+ str(row.get("outcome", "") or ""),
524
+ str(row.get("status", "") or ""),
525
+ ]
526
+ ),
527
+ alias_map,
528
+ )
529
+ domain = normalize_project(str(row.get("domain", "") or ""))
530
+ if domain:
531
+ matched.add(domain)
532
+ if not matched:
533
+ continue
534
+ outcome = str(row.get("outcome", "") or "").lower()
535
+ status = str(row.get("status", "") or "").lower()
536
+ score = 2.5
537
+ if any(token in outcome for token in ("fail", "error", "blocked", "regression")):
538
+ score += 2.0
539
+ if status in {"pending", "blocked", "open"}:
540
+ score += 1.5
541
+ for project in matched:
542
+ bump(project, score, "decisions", "recent decision pressure")
543
+
544
+ ranked = sorted(scoreboard.values(), key=lambda item: item["score"], reverse=True)
545
+ for item in ranked:
546
+ item["score"] = round(item["score"], 2)
547
+ item["reasons"] = item["reasons"][:4]
548
+ return ranked[:8]
549
+
550
+
551
+ def _load_period_syntheses(target_date: str, *, window_days: int) -> list[dict]:
552
+ target_day = datetime.strptime(target_date, "%Y-%m-%d")
553
+ syntheses: list[dict] = []
554
+ for offset in range(window_days):
555
+ date_str = (target_day - timedelta(days=offset)).strftime("%Y-%m-%d")
556
+ path = DEEP_SLEEP_DIR / f"{date_str}-synthesis.json"
557
+ if not path.is_file():
558
+ continue
559
+ try:
560
+ payload = json.loads(path.read_text())
561
+ except Exception:
562
+ continue
563
+ if isinstance(payload, dict):
564
+ syntheses.append(payload)
565
+ syntheses.reverse()
566
+ return syntheses
567
+
568
+
569
+ def _build_period_summary(target_date: str, synthesis: dict, *, kind: str, window_days: int) -> dict:
570
+ target_day = datetime.strptime(target_date, "%Y-%m-%d")
571
+ window_start = (target_day - timedelta(days=max(0, window_days - 1))).strftime("%Y-%m-%d")
572
+ label = (
573
+ f"{target_day.isocalendar().year}-W{target_day.isocalendar().week:02d}"
574
+ if kind == "weekly"
575
+ else target_day.strftime("%Y-%m")
576
+ )
577
+ syntheses = _load_period_syntheses(target_date, window_days=window_days)
578
+ if not any(item.get("date") == target_date for item in syntheses):
579
+ syntheses.append(synthesis)
580
+
581
+ mood_scores = []
582
+ trust_scores = []
583
+ total_corrections = 0
584
+ pattern_counter: Counter[str] = Counter()
585
+ agenda_counter: Counter[str] = Counter()
586
+ for item in syntheses:
587
+ mood = item.get("emotional_day", {}).get("mood_score")
588
+ if isinstance(mood, (int, float)):
589
+ mood_scores.append(float(mood))
590
+ trust = item.get("trust_calibration", {}).get("score")
591
+ if isinstance(trust, (int, float)):
592
+ trust_scores.append(float(trust))
593
+ total_corrections += int(item.get("productivity_day", {}).get("total_corrections", 0) or 0)
594
+ for pattern in item.get("cross_session_patterns", []) or []:
595
+ text = str(pattern.get("pattern", "") or "").strip()
596
+ if text:
597
+ pattern_counter[text] += 1
598
+ for agenda in item.get("morning_agenda", []) or []:
599
+ title = str(agenda.get("title", "") or "").strip()
600
+ if title:
601
+ agenda_counter[title] += 1
602
+
603
+ top_projects = _project_weighting_window(target_date, window_days=window_days)
604
+ avg_mood = round(sum(mood_scores) / len(mood_scores), 3) if mood_scores else None
605
+ avg_trust = round(sum(trust_scores) / len(trust_scores), 1) if trust_scores else None
606
+ top_patterns = [
607
+ {"pattern": pattern, "count": count}
608
+ for pattern, count in pattern_counter.most_common(6)
609
+ ]
610
+ recurring_agenda = [
611
+ {"title": title, "count": count}
612
+ for title, count in agenda_counter.most_common(6)
613
+ ]
614
+
615
+ summary_parts = [f"{len(syntheses)} Deep Sleep run(s)"]
616
+ if top_projects:
617
+ summary_parts.append(f"top focus: {top_projects[0]['project']}")
618
+ if top_patterns:
619
+ summary_parts.append(f"recurring pattern: {top_patterns[0]['pattern']}")
620
+ if avg_trust is not None:
621
+ summary_parts.append(f"avg trust {avg_trust:.1f}")
622
+ summary = " | ".join(summary_parts)
623
+
624
+ return {
625
+ "kind": kind,
626
+ "label": label,
627
+ "window_days": window_days,
628
+ "window_start": window_start,
629
+ "window_end": target_date,
630
+ "generated_at": datetime.now().isoformat(),
631
+ "daily_syntheses": len(syntheses),
632
+ "avg_mood_score": avg_mood,
633
+ "avg_trust_score": avg_trust,
634
+ "total_corrections": total_corrections,
635
+ "top_projects": top_projects,
636
+ "top_patterns": top_patterns,
637
+ "recurring_agenda": recurring_agenda,
638
+ "summary": summary,
639
+ }
640
+
641
+
642
+ def _render_period_summary_markdown(summary: dict) -> str:
643
+ lines = [
644
+ f"# {summary.get('kind', 'period').title()} Deep Sleep Summary — {summary.get('label', '')}",
645
+ "",
646
+ f"- Window: {summary.get('window_start', '')} -> {summary.get('window_end', '')}",
647
+ f"- Deep Sleep runs: {summary.get('daily_syntheses', 0)}",
648
+ ]
649
+ if summary.get("avg_mood_score") is not None:
650
+ lines.append(f"- Avg mood score: {summary['avg_mood_score']:.2f}")
651
+ if summary.get("avg_trust_score") is not None:
652
+ lines.append(f"- Avg trust score: {summary['avg_trust_score']:.1f}")
653
+ lines.append(f"- Total corrections: {summary.get('total_corrections', 0)}")
654
+ lines.append("")
655
+ if summary.get("summary"):
656
+ lines.append(f"> {summary['summary']}")
657
+ lines.append("")
658
+
659
+ if summary.get("top_projects"):
660
+ lines.append("## Top Projects")
661
+ lines.append("")
662
+ for item in summary["top_projects"][:5]:
663
+ lines.append(f"- **{item['project']}** — score {item['score']}")
664
+ if item.get("reasons"):
665
+ lines.append(f" Reasons: {', '.join(item['reasons'])}")
666
+ lines.append("")
667
+
668
+ if summary.get("top_patterns"):
669
+ lines.append("## Recurring Patterns")
670
+ lines.append("")
671
+ for item in summary["top_patterns"][:5]:
672
+ lines.append(f"- {item['pattern']} ({item['count']}x)")
673
+ lines.append("")
674
+
675
+ if summary.get("recurring_agenda"):
676
+ lines.append("## Recurring Agenda")
677
+ lines.append("")
678
+ for item in summary["recurring_agenda"][:5]:
679
+ lines.append(f"- {item['title']} ({item['count']}x)")
680
+ lines.append("")
681
+
682
+ return "\n".join(lines).rstrip() + "\n"
683
+
684
+
685
+ def write_periodic_summaries(target_date: str, synthesis: dict) -> dict:
686
+ outputs: dict[str, str] = {}
687
+ for kind, window_days in (("weekly", 7), ("monthly", 30)):
688
+ summary = _build_period_summary(target_date, synthesis, kind=kind, window_days=window_days)
689
+ label = summary["label"]
690
+ json_path = DEEP_SLEEP_DIR / f"{label}-{kind}-summary.json"
691
+ md_path = DEEP_SLEEP_DIR / f"{label}-{kind}-summary.md"
692
+ json_path.write_text(json.dumps(summary, indent=2, ensure_ascii=False))
693
+ md_path.write_text(_render_period_summary_markdown(summary), encoding="utf-8")
694
+ outputs[f"{kind}_json"] = str(json_path)
695
+ outputs[f"{kind}_markdown"] = str(md_path)
696
+ return outputs
697
+
698
+
312
699
  def generate_session_tone(synthesis: dict, target_date: str) -> dict:
313
700
  """Generate emotional tone guidance for next session startup.
314
701
 
@@ -730,6 +1117,11 @@ def main():
730
1117
  briefing_path = write_morning_briefing(target_date, synthesis)
731
1118
  print(f" Briefing: {briefing_path}")
732
1119
 
1120
+ print("[apply] Writing weekly/monthly Deep Sleep summaries...")
1121
+ periodic_outputs = write_periodic_summaries(target_date, synthesis)
1122
+ for label, path in periodic_outputs.items():
1123
+ print(f" {label}: {path}")
1124
+
733
1125
  # Write applied log
734
1126
  applied_log = {
735
1127
  "date": target_date,
@@ -738,6 +1130,7 @@ def main():
738
1130
  "stats": stats,
739
1131
  "applied_actions": applied_actions,
740
1132
  "summary": synthesis.get("summary", ""),
1133
+ "periodic_summaries": periodic_outputs,
741
1134
  }
742
1135
 
743
1136
  applied_file = DEEP_SLEEP_DIR / f"{target_date}-applied.json"
@@ -390,6 +390,220 @@ def _compact_diary_row(row: dict) -> dict:
390
390
  }
391
391
 
392
392
 
393
+ def _load_project_aliases() -> dict[str, set[str]]:
394
+ atlas_path = NEXO_HOME / "brain" / "project-atlas.json"
395
+ aliases: dict[str, set[str]] = {}
396
+ if not atlas_path.is_file():
397
+ return aliases
398
+ try:
399
+ payload = json.loads(atlas_path.read_text())
400
+ except Exception:
401
+ return aliases
402
+ if not isinstance(payload, dict):
403
+ return aliases
404
+ for key, value in payload.items():
405
+ if str(key).startswith("_"):
406
+ continue
407
+ canonical = str(key).strip().lower()
408
+ alias_set = {canonical, canonical.replace("-", " "), canonical.replace("_", " ")}
409
+ if isinstance(value, dict):
410
+ for alias in value.get("aliases", []) or []:
411
+ alias_value = str(alias or "").strip().lower()
412
+ if alias_value:
413
+ alias_set.add(alias_value)
414
+ alias_set.add(alias_value.replace("-", " "))
415
+ aliases[canonical] = {item for item in alias_set if item}
416
+ return aliases
417
+
418
+
419
+ def _match_projects(text: str, alias_map: dict[str, set[str]]) -> set[str]:
420
+ haystack = str(text or "").strip().lower()
421
+ if not haystack:
422
+ return set()
423
+ matches: set[str] = set()
424
+ for canonical, aliases in alias_map.items():
425
+ for alias in sorted(aliases, key=len, reverse=True):
426
+ if alias and alias in haystack:
427
+ matches.add(canonical)
428
+ break
429
+ return matches
430
+
431
+
432
+ def _priority_weight(value) -> float:
433
+ lowered = str(value or "").strip().lower()
434
+ if lowered in {"critical", "urgent"}:
435
+ return 4.0
436
+ if lowered == "high":
437
+ return 3.0
438
+ if lowered == "medium":
439
+ return 2.0
440
+ if lowered == "low":
441
+ return 1.0
442
+ return 1.5
443
+
444
+
445
+ def _compact_periodic_summary(data: dict) -> dict:
446
+ return {
447
+ "label": data.get("label", ""),
448
+ "window_start": data.get("window_start", ""),
449
+ "window_end": data.get("window_end", ""),
450
+ "summary": str(data.get("summary", "") or "")[:320],
451
+ "top_projects": data.get("top_projects", [])[:4],
452
+ "top_patterns": data.get("top_patterns", [])[:4],
453
+ "avg_mood_score": data.get("avg_mood_score"),
454
+ "avg_trust_score": data.get("avg_trust_score"),
455
+ }
456
+
457
+
458
+ def _load_periodic_summaries(target_date: str, *, kind: str, limit: int = 2) -> list[dict]:
459
+ target_day = datetime.strptime(target_date, "%Y-%m-%d")
460
+ summaries: list[tuple[str, dict]] = []
461
+ pattern = "*-weekly-summary.json" if kind == "weekly" else "*-monthly-summary.json"
462
+ for path in sorted(DEEP_SLEEP_DIR.glob(pattern)):
463
+ try:
464
+ payload = json.loads(path.read_text())
465
+ except Exception:
466
+ continue
467
+ window_end_raw = str(payload.get("window_end", "") or "")
468
+ parsed = _parse_diary_created_at(window_end_raw)
469
+ if parsed and parsed >= target_day:
470
+ continue
471
+ summaries.append((window_end_raw, _compact_periodic_summary(payload)))
472
+ summaries.sort(key=lambda item: item[0], reverse=True)
473
+ return [item for _, item in summaries[:limit]]
474
+
475
+
476
+ def _project_priority_signals(target_day: datetime, compact_diaries: list[dict]) -> list[dict]:
477
+ alias_map = _load_project_aliases()
478
+ scoreboard: dict[str, dict] = {}
479
+
480
+ def bump(project: str, score: float, signal_key: str, reason: str) -> None:
481
+ if not project:
482
+ return
483
+ slot = scoreboard.setdefault(
484
+ project,
485
+ {
486
+ "project": project,
487
+ "score": 0.0,
488
+ "signals": {
489
+ "diary_sessions": 0,
490
+ "learnings": 0,
491
+ "followups": 0,
492
+ "decisions": 0,
493
+ },
494
+ "reasons": [],
495
+ },
496
+ )
497
+ slot["score"] += score
498
+ slot["signals"][signal_key] += 1
499
+ if reason and reason not in slot["reasons"]:
500
+ slot["reasons"].append(reason)
501
+
502
+ for row in compact_diaries:
503
+ created = _parse_diary_created_at(row.get("created_at"))
504
+ recency_bonus = 1.0
505
+ if created:
506
+ age_days = max(0.0, (target_day - created).total_seconds() / 86400)
507
+ recency_bonus = 1.4 if age_days <= 7 else 1.0
508
+ candidates = set()
509
+ domain = str(row.get("domain", "") or "").strip().lower()
510
+ if domain:
511
+ candidates.add(domain)
512
+ candidates |= _match_projects(" ".join([row.get("summary", ""), row.get("self_critique", "")]), alias_map)
513
+ for project in candidates:
514
+ bump(project, 3.0 * recency_bonus, "diary_sessions", "recent session diary activity")
515
+
516
+ learning_rows = safe_query(
517
+ NEXO_DB,
518
+ "SELECT category, title, content, created_at, updated_at, priority, weight, applies_to FROM learnings "
519
+ "ORDER BY COALESCE(updated_at, created_at) DESC LIMIT 160",
520
+ )
521
+ for row in learning_rows:
522
+ text = " ".join(
523
+ [
524
+ str(row.get("applies_to", "") or ""),
525
+ str(row.get("title", "") or ""),
526
+ str(row.get("content", "") or ""),
527
+ str(row.get("category", "") or ""),
528
+ ]
529
+ )
530
+ matched = _match_projects(text, alias_map)
531
+ if not matched:
532
+ continue
533
+ weight = float(row.get("weight", 0) or 0)
534
+ score = 1.0 + _priority_weight(row.get("priority")) + min(2.0, max(0.0, weight))
535
+ for project in matched:
536
+ bump(project, score, "learnings", "recent leverage-bearing learning")
537
+
538
+ followup_rows = safe_query(
539
+ NEXO_DB,
540
+ "SELECT id, description, date, status, priority, created_at, updated_at, reasoning FROM followups "
541
+ "WHERE status NOT IN ('COMPLETED', 'CANCELLED') ORDER BY date ASC, created_at ASC LIMIT 120",
542
+ )
543
+ for row in followup_rows:
544
+ matched = _match_projects(
545
+ " ".join(
546
+ [
547
+ str(row.get("description", "") or ""),
548
+ str(row.get("reasoning", "") or ""),
549
+ ]
550
+ ),
551
+ alias_map,
552
+ )
553
+ if not matched:
554
+ continue
555
+ overdue_bonus = 0.0
556
+ due_value = str(row.get("date", "") or "")
557
+ try:
558
+ if due_value:
559
+ due_dt = datetime.strptime(due_value[:10], "%Y-%m-%d")
560
+ if due_dt <= target_day:
561
+ overdue_bonus = 1.5
562
+ except Exception:
563
+ overdue_bonus = 0.0
564
+ score = 1.5 + _priority_weight(row.get("priority")) + overdue_bonus
565
+ for project in matched:
566
+ bump(project, score, "followups", "open followup pressure")
567
+
568
+ decision_rows = safe_query(
569
+ NEXO_DB,
570
+ "SELECT domain, outcome, status, reasoning, created_at, review_due_at FROM decisions "
571
+ "ORDER BY COALESCE(created_at, review_due_at) DESC LIMIT 120",
572
+ )
573
+ for row in decision_rows:
574
+ matched = set()
575
+ domain = str(row.get("domain", "") or "").strip().lower()
576
+ if domain:
577
+ matched.add(domain)
578
+ matched |= _match_projects(
579
+ " ".join(
580
+ [
581
+ str(row.get("reasoning", "") or ""),
582
+ str(row.get("outcome", "") or ""),
583
+ str(row.get("status", "") or ""),
584
+ ]
585
+ ),
586
+ alias_map,
587
+ )
588
+ if not matched:
589
+ continue
590
+ outcome = str(row.get("outcome", "") or "").lower()
591
+ status = str(row.get("status", "") or "").lower()
592
+ score = 2.5
593
+ if any(token in outcome for token in ("fail", "error", "blocked", "regression")):
594
+ score += 2.0
595
+ if status in {"pending", "blocked", "open"}:
596
+ score += 1.5
597
+ for project in matched:
598
+ bump(project, score, "decisions", "recent decision pressure")
599
+
600
+ ranked = sorted(scoreboard.values(), key=lambda item: item["score"], reverse=True)
601
+ for item in ranked:
602
+ item["score"] = round(item["score"], 2)
603
+ item["reasons"] = item["reasons"][:4]
604
+ return ranked[:8]
605
+
606
+
393
607
  def collect_long_horizon_context(
394
608
  target_date: str,
395
609
  *,
@@ -497,6 +711,10 @@ def collect_long_horizon_context(
497
711
  "created_at": created.isoformat(),
498
712
  })
499
713
 
714
+ weekly_summaries = _load_periodic_summaries(target_date, kind="weekly", limit=2)
715
+ monthly_summaries = _load_periodic_summaries(target_date, kind="monthly", limit=2)
716
+ project_priority_signals = _project_priority_signals(target_day, compact_diaries)
717
+
500
718
  return {
501
719
  "horizon_days": horizon_days,
502
720
  "recent_window_days": recent_days,
@@ -508,6 +726,9 @@ def collect_long_horizon_context(
508
726
  "recurring_mental_states": recurring_states.most_common(8),
509
727
  "recurring_self_critiques": recurring_critiques.most_common(6),
510
728
  "stale_followups": older_than_week[:12],
729
+ "project_priority_signals": project_priority_signals,
730
+ "weekly_summaries": weekly_summaries,
731
+ "monthly_summaries": monthly_summaries,
511
732
  }
512
733
 
513
734
 
@@ -13,6 +13,12 @@ Read the extractions file provided below. It contains per-session findings inclu
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
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
+ That long-horizon file may also contain:
17
+ - weekly summaries
18
+ - monthly summaries
19
+ - project priority signals based on diary activity, followup pressure, learnings, and decision outcomes
20
+
21
+ Use those signals to weight importance, leverage, and chronic risk instead of treating all projects equally.
16
22
 
17
23
  Synthesize across all sessions:
18
24
 
@@ -23,6 +29,7 @@ Synthesize across all sessions:
23
29
  - Themes that recur across multiple weeks, not just today
24
30
  - Cross-domain connections where an older learning or session sample explains a current issue
25
31
  - Topics repeatedly mentioned over time but never formalized into a learning or followup
32
+ - Project pressure that is rising because of repeated diary mentions, open followups, or adverse outcomes
26
33
 
27
34
  ### 2. Morning Agenda
28
35
  Generate a prioritized agenda for the next morning:
@@ -59,6 +66,12 @@ Consolidate `abandoned_projects` from all sessions:
59
66
  - Cross-reference across sessions — was the abandoned work picked up later in another session?
60
67
  - Only flag projects that are truly abandoned (no followup AND not resumed)
61
68
 
69
+ ### 6.5 Weekly / Monthly Horizon
70
+ When the long-horizon payload includes weekly or monthly summaries:
71
+ - use them to detect drift across horizons, not just within a single day
72
+ - identify which priorities are rising, stable, or cooling down
73
+ - prefer high-leverage projects when multiple agenda items compete for attention
74
+
62
75
  ### 7. Trust Calibration (CRITICAL)
63
76
  Score the agent's performance for the day on a scale of 0-100. This score becomes the agent's trust score and directly affects its autonomy level the next day. Be fair but honest.
64
77