nexo-brain 2.6.18 → 2.6.21
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 +9 -4
- package/bin/nexo-brain.js +17 -12
- package/package.json +1 -1
- package/src/agent_runner.py +3 -0
- package/src/client_preferences.py +12 -14
- package/src/client_sync.py +1 -1
- package/src/runtime_power.py +8 -4
- package/src/scripts/deep-sleep/apply_findings.py +435 -8
- package/src/scripts/deep-sleep/synthesize-prompt.md +23 -0
- package/src/scripts/deep-sleep/synthesize.py +94 -0
- package/templates/nexo_helper.py +44 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.21",
|
|
4
4
|
"description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "NEXO Brain",
|
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ That means NEXO now manages not only the shared runtime and MCP wiring, but also
|
|
|
38
38
|
- For Codex specifically, `nexo chat` and Codex headless automation inject the current bootstrap explicitly, so Codex starts as NEXO even when plain global Codex startup is inconsistent about global instructions.
|
|
39
39
|
- Deep Sleep now reads both Claude Code and Codex transcript stores, so overnight analysis still works even when the user spends the day in Codex.
|
|
40
40
|
|
|
41
|
-
Version `2.6.14` closes those parity gaps in practice, `2.6.15` hardens the installed-runtime migration path so existing users actually receive the managed bootstrap updates cleanly, `2.6.16` pushes the system further in three directions, `2.6.17` finishes the annoying last-mile migration bugs for real existing installs,
|
|
41
|
+
Version `2.6.14` closes those parity gaps in practice, `2.6.15` hardens the installed-runtime migration path so existing users actually receive the managed bootstrap updates cleanly, `2.6.16` pushes the system further in three directions, `2.6.17` finishes the annoying last-mile migration bugs for real existing installs, `2.6.18` tightens the remaining practical gaps around manual Codex use, Deep Sleep horizon artifacts, and retrieval honesty, `2.6.20` makes the recommended Claude profile explicit across installer, runtime defaults, existing installs, and the update path itself with `Opus 4.6 with 1M context`, and `2.6.21` upgrades Deep Sleep from passive nightly analysis toward concrete engineering action.
|
|
42
42
|
|
|
43
43
|
- Codex now gets managed global bootstrap/model sync in `~/.codex/config.toml`, so sessions opened outside `nexo chat` are much less likely to start as plain Codex.
|
|
44
44
|
- Codex config now also persists a managed `mcp_servers.nexo` entry, so the shared brain survives even if ad-hoc Codex MCP state drifts.
|
|
@@ -47,6 +47,7 @@ Version `2.6.14` closes those parity gaps in practice, `2.6.15` hardens the inst
|
|
|
47
47
|
- Retrieval explanations now surface confidence and the auto-strategy that fired, while associative expansion trims itself back to `top_k` instead of leaking low-signal neighbors.
|
|
48
48
|
- Deep Sleep now blends recent context with older context over a 60-day horizon, and memory decay now tracks per-memory `stability` and `difficulty` instead of relying only on global decay constants.
|
|
49
49
|
- Deep Sleep now also carries project-priority weighting into its long-horizon context and writes reusable weekly/monthly summary artifacts instead of reasoning only day by day.
|
|
50
|
+
- Deep Sleep now semantically deduplicates followups, consolidates overlapping learnings, flags contradictory learnings for review, and backfills explicit engineering followups when recurring patterns imply a concrete fix.
|
|
50
51
|
- Existing installs that already had NEXO connected to Codex now backfill that client state automatically during update/sync, so the managed Codex bootstrap actually lands without manual cleanup.
|
|
51
52
|
- Bootstrap docs now fall back to the operator name `NEXO` when local metadata is blank, avoiding broken headings in `CLAUDE.md` and `AGENTS.md`.
|
|
52
53
|
|
|
@@ -645,7 +646,7 @@ nexo scripts list # See your personal scripts
|
|
|
645
646
|
During install, NEXO now asks which interactive clients you want to connect, which one `nexo chat` should open by default, whether to enable background automation, which backend should run that automation, and which model profile each active terminal/backend should use. Shared brain stays on in every mode.
|
|
646
647
|
|
|
647
648
|
Recommended defaults:
|
|
648
|
-
- Claude Code: `Opus
|
|
649
|
+
- Claude Code: `Opus 4.6 with 1M context`
|
|
649
650
|
- Codex: `gpt-5.4` with `xhigh` reasoning
|
|
650
651
|
|
|
651
652
|
Or use the shell alias created during install (e.g. `atlas`), which now runs `nexo chat .` so it opens whichever terminal client you selected as default.
|
|
@@ -662,6 +663,9 @@ NEXO is being hardened in public, and the best contributions now are not only co
|
|
|
662
663
|
|
|
663
664
|
The project still recommends Claude Code as the primary path, but contributions that improve Codex, client parity, installer clarity, and ecosystem integrations are especially valuable.
|
|
664
665
|
|
|
666
|
+
Maintainers and contributors touching startup, bootstrap, Deep Sleep, or shared-brain behavior should also use the client parity checklist:
|
|
667
|
+
- [docs/client-parity-checklist.md](docs/client-parity-checklist.md)
|
|
668
|
+
|
|
665
669
|
### What Gets Installed
|
|
666
670
|
|
|
667
671
|
| Component | What | Where |
|
|
@@ -723,7 +727,7 @@ The Doctor system reads existing health artifacts (immune, watchdog, self-audit)
|
|
|
723
727
|
- **macOS or Linux** (Windows via [WSL](https://learn.microsoft.com/en-us/windows/wsl/install))
|
|
724
728
|
- **Node.js 18+** (for the installer)
|
|
725
729
|
- **Claude Code is the primary recommended client.** It remains the most mature NEXO path: native hooks, the most battle-tested automation contract, and the clearest parity with historical production behavior.
|
|
726
|
-
- **Recommended profiles:** Claude Code + `Opus
|
|
730
|
+
- **Recommended profiles:** Claude Code + `Opus 4.6 with 1M context`; Codex + `gpt-5.4` with `xhigh` reasoning if you prefer Codex as your terminal or automation backend.
|
|
727
731
|
- Python 3, Homebrew, and the selected required client/backend can be installed automatically when NEXO has a supported installer path for that dependency.
|
|
728
732
|
|
|
729
733
|
## Architecture
|
|
@@ -830,7 +834,7 @@ NEXO Brain is designed as an MCP server. Claude Code remains the primary recomme
|
|
|
830
834
|
npx nexo-brain
|
|
831
835
|
```
|
|
832
836
|
|
|
833
|
-
All 150+ tools are available immediately after installation. The installer configures Claude Code's `~/.claude/settings.json` automatically. The recommended Claude profile is `Opus
|
|
837
|
+
All 150+ tools are available immediately after installation. The installer configures Claude Code's `~/.claude/settings.json` automatically. The recommended Claude profile is `Opus 4.6 with 1M context`.
|
|
834
838
|
|
|
835
839
|
### Claude Desktop
|
|
836
840
|
|
|
@@ -920,6 +924,7 @@ If NEXO Brain is useful to you, consider:
|
|
|
920
924
|
- **[Sponsor on GitHub](https://github.com/sponsors/wazionapps)** — support ongoing development directly
|
|
921
925
|
- **Share your experience** — tell others how you're using cognitive memory in your AI workflows
|
|
922
926
|
- **Contribute** — see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. Issues and PRs welcome
|
|
927
|
+
- **Client parity / shared-brain maintenance** — see [docs/client-parity-checklist.md](docs/client-parity-checklist.md)
|
|
923
928
|
|
|
924
929
|
[](https://star-history.com/#wazionapps/nexo&Date)
|
|
925
930
|
|
package/bin/nexo-brain.js
CHANGED
|
@@ -33,6 +33,10 @@ const LAUNCH_AGENTS = path.join(
|
|
|
33
33
|
);
|
|
34
34
|
const MACOS_FDA_SETTINGS_URL = "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles";
|
|
35
35
|
const PUBLIC_CONTRIBUTION_UPSTREAM = "wazionapps/nexo";
|
|
36
|
+
const DEFAULT_CLAUDE_CODE_MODEL = "claude-opus-4-6[1m]";
|
|
37
|
+
const DEFAULT_CLAUDE_CODE_REASONING_EFFORT = "";
|
|
38
|
+
const DEFAULT_CODEX_MODEL = "gpt-5.4";
|
|
39
|
+
const DEFAULT_CODEX_REASONING_EFFORT = "xhigh";
|
|
36
40
|
|
|
37
41
|
function isEphemeralInstall(nexoHome) {
|
|
38
42
|
const homeDir = require("os").homedir();
|
|
@@ -480,12 +484,12 @@ function getDefaultSchedule(timezone) {
|
|
|
480
484
|
automation_backend: "claude_code",
|
|
481
485
|
client_runtime_profiles: {
|
|
482
486
|
claude_code: {
|
|
483
|
-
model:
|
|
484
|
-
reasoning_effort:
|
|
487
|
+
model: DEFAULT_CLAUDE_CODE_MODEL,
|
|
488
|
+
reasoning_effort: DEFAULT_CLAUDE_CODE_REASONING_EFFORT,
|
|
485
489
|
},
|
|
486
490
|
codex: {
|
|
487
|
-
model:
|
|
488
|
-
reasoning_effort:
|
|
491
|
+
model: DEFAULT_CODEX_MODEL,
|
|
492
|
+
reasoning_effort: DEFAULT_CODEX_REASONING_EFFORT,
|
|
489
493
|
},
|
|
490
494
|
},
|
|
491
495
|
client_install_preferences: {
|
|
@@ -656,12 +660,12 @@ async function askChoice(question, options, defaultValue) {
|
|
|
656
660
|
function defaultClientRuntimeProfiles() {
|
|
657
661
|
return {
|
|
658
662
|
claude_code: {
|
|
659
|
-
model:
|
|
660
|
-
reasoning_effort:
|
|
663
|
+
model: DEFAULT_CLAUDE_CODE_MODEL,
|
|
664
|
+
reasoning_effort: DEFAULT_CLAUDE_CODE_REASONING_EFFORT,
|
|
661
665
|
},
|
|
662
666
|
codex: {
|
|
663
|
-
model:
|
|
664
|
-
reasoning_effort:
|
|
667
|
+
model: DEFAULT_CODEX_MODEL,
|
|
668
|
+
reasoning_effort: DEFAULT_CODEX_REASONING_EFFORT,
|
|
665
669
|
},
|
|
666
670
|
};
|
|
667
671
|
}
|
|
@@ -690,10 +694,11 @@ function runtimeProfileCatalog(lang, client) {
|
|
|
690
694
|
customModelQuestionEn: ` Enter the model alias/name for ${runtimeClientLabel(client)} > `,
|
|
691
695
|
customEffortQuestion: ` Escribe el effort para ${runtimeClientLabel(client)} (vacío = default) > `,
|
|
692
696
|
customEffortQuestionEn: ` Enter the effort for ${runtimeClientLabel(client)} (blank = default) > `,
|
|
693
|
-
modelDefault:
|
|
697
|
+
modelDefault: DEFAULT_CLAUDE_CODE_MODEL,
|
|
694
698
|
effortDefault: "",
|
|
695
699
|
modelOptions: [
|
|
696
|
-
{ value:
|
|
700
|
+
{ value: DEFAULT_CLAUDE_CODE_MODEL, label: `Opus 4.6 with 1M context${recommended}` },
|
|
701
|
+
{ value: "claude-opus-4-6", label: "Opus 4.6" },
|
|
697
702
|
{ value: "sonnet", label: "Sonnet latest" },
|
|
698
703
|
{ value: "custom", label: lang === "es" ? "Modelo personalizado" : "Custom model" },
|
|
699
704
|
],
|
|
@@ -715,8 +720,8 @@ function runtimeProfileCatalog(lang, client) {
|
|
|
715
720
|
customModelQuestionEn: ` Enter the model name for ${runtimeClientLabel(client)} > `,
|
|
716
721
|
customEffortQuestion: ` Escribe el reasoning effort para ${runtimeClientLabel(client)} > `,
|
|
717
722
|
customEffortQuestionEn: ` Enter the reasoning effort for ${runtimeClientLabel(client)} > `,
|
|
718
|
-
modelDefault:
|
|
719
|
-
effortDefault:
|
|
723
|
+
modelDefault: DEFAULT_CODEX_MODEL,
|
|
724
|
+
effortDefault: DEFAULT_CODEX_REASONING_EFFORT,
|
|
720
725
|
modelOptions: [
|
|
721
726
|
{ value: "gpt-5.4", label: `GPT-5.4${recommended}` },
|
|
722
727
|
{ value: "gpt-5.4-pro", label: "GPT-5.4 Pro" },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.21",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
5
|
"description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
|
|
6
6
|
"homepage": "https://nexo-brain.com",
|
package/src/agent_runner.py
CHANGED
|
@@ -229,6 +229,9 @@ def _resolve_runtime_model_and_effort(
|
|
|
229
229
|
if client == CLIENT_CODEX:
|
|
230
230
|
if not requested_model or requested_model.lower() in CLAUDE_LEGACY_MODEL_HINTS:
|
|
231
231
|
requested_model = profile["model"]
|
|
232
|
+
elif client == CLIENT_CLAUDE_CODE:
|
|
233
|
+
if not requested_model or requested_model.lower() in CLAUDE_LEGACY_MODEL_HINTS:
|
|
234
|
+
requested_model = profile["model"]
|
|
232
235
|
elif not requested_model:
|
|
233
236
|
requested_model = profile["model"]
|
|
234
237
|
|
|
@@ -36,18 +36,10 @@ INSTALL_PREFERENCE_KEYS = {
|
|
|
36
36
|
"skip",
|
|
37
37
|
"manual",
|
|
38
38
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
},
|
|
44
|
-
CLIENT_CODEX: {
|
|
45
|
-
"model": "gpt-5.4",
|
|
46
|
-
"reasoning_effort": "xhigh",
|
|
47
|
-
},
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
|
|
39
|
+
DEFAULT_CLAUDE_CODE_MODEL = "claude-opus-4-6[1m]"
|
|
40
|
+
DEFAULT_CLAUDE_CODE_REASONING_EFFORT = ""
|
|
41
|
+
DEFAULT_CODEX_MODEL = "gpt-5.4"
|
|
42
|
+
DEFAULT_CODEX_REASONING_EFFORT = "xhigh"
|
|
51
43
|
def _user_home() -> Path:
|
|
52
44
|
return Path(os.environ.get("HOME", str(Path.home()))).expanduser()
|
|
53
45
|
|
|
@@ -225,8 +217,14 @@ def normalize_client_install_preferences(value) -> dict[str, str]:
|
|
|
225
217
|
|
|
226
218
|
def default_client_runtime_profiles() -> dict[str, dict[str, str]]:
|
|
227
219
|
return {
|
|
228
|
-
|
|
229
|
-
|
|
220
|
+
CLIENT_CLAUDE_CODE: {
|
|
221
|
+
"model": DEFAULT_CLAUDE_CODE_MODEL,
|
|
222
|
+
"reasoning_effort": DEFAULT_CLAUDE_CODE_REASONING_EFFORT,
|
|
223
|
+
},
|
|
224
|
+
CLIENT_CODEX: {
|
|
225
|
+
"model": DEFAULT_CODEX_MODEL,
|
|
226
|
+
"reasoning_effort": DEFAULT_CODEX_REASONING_EFFORT,
|
|
227
|
+
},
|
|
230
228
|
}
|
|
231
229
|
|
|
232
230
|
|
package/src/client_sync.py
CHANGED
|
@@ -55,7 +55,7 @@ except Exception:
|
|
|
55
55
|
|
|
56
56
|
def resolve_client_runtime_profile(client: str, preferences: dict | None = None) -> dict:
|
|
57
57
|
defaults = {
|
|
58
|
-
"claude_code": {"model": "opus", "reasoning_effort": ""},
|
|
58
|
+
"claude_code": {"model": "claude-opus-4-6[1m]", "reasoning_effort": ""},
|
|
59
59
|
"codex": {"model": "gpt-5.4", "reasoning_effort": "xhigh"},
|
|
60
60
|
}
|
|
61
61
|
return dict(defaults.get(client, {}))
|
package/src/runtime_power.py
CHANGED
|
@@ -65,6 +65,10 @@ MACOS_FDA_PROBE_PATHS = (
|
|
|
65
65
|
Path.home() / "Library" / "Safari",
|
|
66
66
|
Path.home() / "Library" / "Application Support" / "AddressBook",
|
|
67
67
|
)
|
|
68
|
+
DEFAULT_CLAUDE_CODE_MODEL = "claude-opus-4-6[1m]"
|
|
69
|
+
DEFAULT_CLAUDE_CODE_REASONING_EFFORT = ""
|
|
70
|
+
DEFAULT_CODEX_MODEL = "gpt-5.4"
|
|
71
|
+
DEFAULT_CODEX_REASONING_EFFORT = "xhigh"
|
|
68
72
|
|
|
69
73
|
|
|
70
74
|
def _schedule_defaults() -> dict:
|
|
@@ -81,12 +85,12 @@ def _schedule_defaults() -> dict:
|
|
|
81
85
|
"automation_backend": "claude_code",
|
|
82
86
|
"client_runtime_profiles": {
|
|
83
87
|
"claude_code": {
|
|
84
|
-
"model":
|
|
85
|
-
"reasoning_effort":
|
|
88
|
+
"model": DEFAULT_CLAUDE_CODE_MODEL,
|
|
89
|
+
"reasoning_effort": DEFAULT_CLAUDE_CODE_REASONING_EFFORT,
|
|
86
90
|
},
|
|
87
91
|
"codex": {
|
|
88
|
-
"model":
|
|
89
|
-
"reasoning_effort":
|
|
92
|
+
"model": DEFAULT_CODEX_MODEL,
|
|
93
|
+
"reasoning_effort": DEFAULT_CODEX_REASONING_EFFORT,
|
|
90
94
|
},
|
|
91
95
|
},
|
|
92
96
|
"client_install_preferences": {
|
|
@@ -17,10 +17,12 @@ Environment variables:
|
|
|
17
17
|
import hashlib
|
|
18
18
|
import json
|
|
19
19
|
import os
|
|
20
|
+
import re
|
|
20
21
|
import sqlite3
|
|
21
22
|
import sys
|
|
22
23
|
from collections import Counter
|
|
23
24
|
from datetime import datetime, timedelta
|
|
25
|
+
from difflib import SequenceMatcher
|
|
24
26
|
from pathlib import Path
|
|
25
27
|
|
|
26
28
|
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
@@ -34,6 +36,36 @@ COGNITIVE_DB = NEXO_HOME / "data" / "cognitive.db"
|
|
|
34
36
|
OPERATIONS_DIR = NEXO_HOME / "operations"
|
|
35
37
|
BACKUP_DIR = DEEP_SLEEP_DIR # backups stored alongside outputs
|
|
36
38
|
|
|
39
|
+
STOPWORDS = {
|
|
40
|
+
"the", "a", "an", "and", "or", "but", "with", "for", "from", "into", "onto",
|
|
41
|
+
"that", "this", "these", "those", "have", "has", "had", "will", "would",
|
|
42
|
+
"could", "should", "must", "need", "needs", "your", "their", "there", "here",
|
|
43
|
+
"about", "before", "after", "during", "through", "without", "within", "while",
|
|
44
|
+
"que", "con", "para", "por", "los", "las", "una", "uno", "sobre", "desde",
|
|
45
|
+
"cuando", "como", "pero", "todo", "toda", "cada", "into", "across", "using",
|
|
46
|
+
}
|
|
47
|
+
CONCRETE_ACTION_VERBS = {
|
|
48
|
+
"add", "implement", "create", "write", "build", "introduce", "enforce",
|
|
49
|
+
"automate", "validate", "check", "verify", "guard", "fix", "migrate",
|
|
50
|
+
"review", "reconcile", "pin", "sync", "instrument",
|
|
51
|
+
}
|
|
52
|
+
NEGATION_PATTERNS = (
|
|
53
|
+
"do not", "don't", "never", "avoid", "skip", "without", "forbid", "forbidden",
|
|
54
|
+
"disable", "disabled", "remove", "ban", "bypass",
|
|
55
|
+
)
|
|
56
|
+
CONTRADICTION_PAIRS = (
|
|
57
|
+
("enable", "disable"),
|
|
58
|
+
("use", "avoid"),
|
|
59
|
+
("add", "remove"),
|
|
60
|
+
("allow", "forbid"),
|
|
61
|
+
("always", "never"),
|
|
62
|
+
("before", "after"),
|
|
63
|
+
("require", "skip"),
|
|
64
|
+
("validate", "bypass"),
|
|
65
|
+
("include", "exclude"),
|
|
66
|
+
)
|
|
67
|
+
TABLE_COLUMNS_CACHE: dict[tuple[str, str], set[str]] = {}
|
|
68
|
+
|
|
37
69
|
|
|
38
70
|
def generate_run_id(target_date: str) -> str:
|
|
39
71
|
"""Generate a unique run ID for this execution."""
|
|
@@ -75,41 +107,435 @@ def backup_db(db_path: Path, run_id: str) -> Path | None:
|
|
|
75
107
|
return None
|
|
76
108
|
|
|
77
109
|
|
|
110
|
+
def _table_columns(db_path: Path, table: str) -> set[str]:
|
|
111
|
+
cache_key = (str(db_path), table)
|
|
112
|
+
if cache_key in TABLE_COLUMNS_CACHE:
|
|
113
|
+
return TABLE_COLUMNS_CACHE[cache_key]
|
|
114
|
+
if not db_path.exists():
|
|
115
|
+
TABLE_COLUMNS_CACHE[cache_key] = set()
|
|
116
|
+
return set()
|
|
117
|
+
try:
|
|
118
|
+
conn = sqlite3.connect(str(db_path))
|
|
119
|
+
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
|
120
|
+
conn.close()
|
|
121
|
+
except Exception:
|
|
122
|
+
TABLE_COLUMNS_CACHE[cache_key] = set()
|
|
123
|
+
return set()
|
|
124
|
+
cols = {str(row[1]) for row in rows}
|
|
125
|
+
TABLE_COLUMNS_CACHE[cache_key] = cols
|
|
126
|
+
return cols
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _row_dict(row) -> dict:
|
|
130
|
+
if row is None:
|
|
131
|
+
return {}
|
|
132
|
+
if isinstance(row, sqlite3.Row):
|
|
133
|
+
return dict(row)
|
|
134
|
+
return dict(zip(row.keys(), row)) if hasattr(row, "keys") else dict(row)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _normalize_text(value: str) -> str:
|
|
138
|
+
text = str(value or "").lower()
|
|
139
|
+
text = re.sub(r"https?://\S+", " ", text)
|
|
140
|
+
text = re.sub(r"[^a-z0-9_/\-\s]+", " ", text)
|
|
141
|
+
text = re.sub(r"\s+", " ", text)
|
|
142
|
+
return text.strip()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _tokenize(value: str) -> list[str]:
|
|
146
|
+
tokens = re.findall(r"[a-z0-9_/-]+", _normalize_text(value))
|
|
147
|
+
return [token for token in tokens if len(token) > 2 and token not in STOPWORDS]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _text_similarity(left: str, right: str) -> float:
|
|
151
|
+
normalized_left = _normalize_text(left)
|
|
152
|
+
normalized_right = _normalize_text(right)
|
|
153
|
+
if not normalized_left or not normalized_right:
|
|
154
|
+
return 0.0
|
|
155
|
+
if normalized_left == normalized_right:
|
|
156
|
+
return 1.0
|
|
157
|
+
|
|
158
|
+
left_tokens = set(_tokenize(normalized_left))
|
|
159
|
+
right_tokens = set(_tokenize(normalized_right))
|
|
160
|
+
shared = left_tokens & right_tokens
|
|
161
|
+
if not shared:
|
|
162
|
+
return SequenceMatcher(None, normalized_left, normalized_right).ratio()
|
|
163
|
+
|
|
164
|
+
seq = SequenceMatcher(None, normalized_left, normalized_right).ratio()
|
|
165
|
+
jaccard = len(shared) / len(left_tokens | right_tokens) if (left_tokens or right_tokens) else 0.0
|
|
166
|
+
overlap = len(shared) / min(len(left_tokens), len(right_tokens)) if min(len(left_tokens), len(right_tokens)) else 0.0
|
|
167
|
+
containment = (
|
|
168
|
+
1.0
|
|
169
|
+
if normalized_left in normalized_right or normalized_right in normalized_left
|
|
170
|
+
else 0.0
|
|
171
|
+
)
|
|
172
|
+
return round(max((seq * 0.45) + (jaccard * 0.2) + (overlap * 0.35), overlap, (containment * 0.8) + (seq * 0.2)), 4)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _is_concrete_action(text: str) -> bool:
|
|
176
|
+
tokens = set(_tokenize(text))
|
|
177
|
+
return bool(tokens & CONCRETE_ACTION_VERBS)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _prefer_due_date(current_value, new_value) -> str:
|
|
181
|
+
current = _parse_any_datetime(current_value)
|
|
182
|
+
new = _parse_any_datetime(new_value)
|
|
183
|
+
if new and (not current or new <= current):
|
|
184
|
+
return str(new_value or "")
|
|
185
|
+
return str(current_value or "")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _append_note(base: str, note: str) -> str:
|
|
189
|
+
base = str(base or "").strip()
|
|
190
|
+
note = str(note or "").strip()
|
|
191
|
+
if not note:
|
|
192
|
+
return base
|
|
193
|
+
if not base:
|
|
194
|
+
return note
|
|
195
|
+
if note.lower() in base.lower():
|
|
196
|
+
return base
|
|
197
|
+
return f"{base}\n\n{note}"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _contains_negation(text: str) -> bool:
|
|
201
|
+
lowered = _normalize_text(text)
|
|
202
|
+
return any(token in lowered for token in NEGATION_PATTERNS)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _negated_action_verbs(text: str) -> set[str]:
|
|
206
|
+
lowered = _normalize_text(text)
|
|
207
|
+
matches = set()
|
|
208
|
+
for pattern in (r"(?:never|avoid|skip|disable|remove|forbid|bypass)\s+([a-z0-9_-]+)", r"(?:do not|don't)\s+([a-z0-9_-]+)"):
|
|
209
|
+
matches.update(re.findall(pattern, lowered))
|
|
210
|
+
return {match for match in matches if len(match) > 2}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _looks_contradictory(existing_text: str, new_text: str) -> bool:
|
|
214
|
+
existing_norm = _normalize_text(existing_text)
|
|
215
|
+
new_norm = _normalize_text(new_text)
|
|
216
|
+
if not existing_norm or not new_norm:
|
|
217
|
+
return False
|
|
218
|
+
existing_tokens = set(_tokenize(existing_norm))
|
|
219
|
+
new_tokens = set(_tokenize(new_norm))
|
|
220
|
+
if len(existing_tokens & new_tokens) < 3:
|
|
221
|
+
return False
|
|
222
|
+
existing_negated_verbs = _negated_action_verbs(existing_norm)
|
|
223
|
+
new_negated_verbs = _negated_action_verbs(new_norm)
|
|
224
|
+
if existing_negated_verbs & new_tokens and not existing_negated_verbs & new_negated_verbs:
|
|
225
|
+
return True
|
|
226
|
+
if new_negated_verbs & existing_tokens and not existing_negated_verbs & new_negated_verbs:
|
|
227
|
+
return True
|
|
228
|
+
if _contains_negation(existing_norm) != _contains_negation(new_norm):
|
|
229
|
+
return True
|
|
230
|
+
for positive, negative in CONTRADICTION_PAIRS:
|
|
231
|
+
existing_has_pair = positive in existing_norm or negative in existing_norm
|
|
232
|
+
new_has_pair = positive in new_norm or negative in new_norm
|
|
233
|
+
if existing_has_pair and new_has_pair:
|
|
234
|
+
if (positive in existing_norm and negative in new_norm) or (negative in existing_norm and positive in new_norm):
|
|
235
|
+
return True
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _fetch_open_followups() -> list[dict]:
|
|
240
|
+
if not NEXO_DB.exists():
|
|
241
|
+
return []
|
|
242
|
+
conn = sqlite3.connect(str(NEXO_DB))
|
|
243
|
+
conn.row_factory = sqlite3.Row
|
|
244
|
+
cols = _table_columns(NEXO_DB, "followups")
|
|
245
|
+
reasoning_sql = ", reasoning" if "reasoning" in cols else ""
|
|
246
|
+
verification_sql = ", verification" if "verification" in cols else ""
|
|
247
|
+
try:
|
|
248
|
+
rows = conn.execute(
|
|
249
|
+
"SELECT id, description, date, status"
|
|
250
|
+
f"{verification_sql}{reasoning_sql} "
|
|
251
|
+
"FROM followups WHERE status NOT LIKE 'COMPLETED%' "
|
|
252
|
+
"AND status NOT IN ('DELETED','archived','blocked','waiting','CANCELLED')"
|
|
253
|
+
).fetchall()
|
|
254
|
+
finally:
|
|
255
|
+
conn.close()
|
|
256
|
+
return [dict(row) for row in rows]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _find_similar_followup(description: str, threshold: float = 0.58) -> dict | None:
|
|
260
|
+
candidates = []
|
|
261
|
+
query = str(description or "").strip()
|
|
262
|
+
if not query:
|
|
263
|
+
return None
|
|
264
|
+
query_tokens = set(_tokenize(query))
|
|
265
|
+
for row in _fetch_open_followups():
|
|
266
|
+
haystack = " ".join(
|
|
267
|
+
[
|
|
268
|
+
str(row.get("description", "") or ""),
|
|
269
|
+
str(row.get("verification", "") or ""),
|
|
270
|
+
str(row.get("reasoning", "") or ""),
|
|
271
|
+
]
|
|
272
|
+
)
|
|
273
|
+
haystack_tokens = set(_tokenize(haystack))
|
|
274
|
+
if len(query_tokens & haystack_tokens) < 2 and _normalize_text(query) not in _normalize_text(haystack):
|
|
275
|
+
continue
|
|
276
|
+
score = _text_similarity(query, haystack)
|
|
277
|
+
if score >= threshold:
|
|
278
|
+
candidates.append({**row, "_similarity": score})
|
|
279
|
+
if not candidates:
|
|
280
|
+
return None
|
|
281
|
+
candidates.sort(key=lambda item: item["_similarity"], reverse=True)
|
|
282
|
+
return candidates[0]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _touch_existing_followup(existing: dict, *, description: str, date: str = "", reasoning_note: str = "") -> dict:
|
|
286
|
+
cols = _table_columns(NEXO_DB, "followups")
|
|
287
|
+
if not cols:
|
|
288
|
+
return {"success": False, "error": "followups table not found"}
|
|
289
|
+
|
|
290
|
+
updates: dict[str, object] = {}
|
|
291
|
+
existing_description = str(existing.get("description", "") or "")
|
|
292
|
+
if _is_concrete_action(description) and not _is_concrete_action(existing_description):
|
|
293
|
+
updates["description"] = description
|
|
294
|
+
preferred_date = _prefer_due_date(existing.get("date", ""), date)
|
|
295
|
+
if preferred_date and preferred_date != str(existing.get("date", "") or "") and "date" in cols:
|
|
296
|
+
updates["date"] = preferred_date
|
|
297
|
+
if "reasoning" in cols and reasoning_note:
|
|
298
|
+
updates["reasoning"] = _append_note(existing.get("reasoning", ""), reasoning_note)
|
|
299
|
+
if "updated_at" in cols:
|
|
300
|
+
updates["updated_at"] = datetime.now().timestamp()
|
|
301
|
+
|
|
302
|
+
if updates:
|
|
303
|
+
conn = sqlite3.connect(str(NEXO_DB))
|
|
304
|
+
set_clause = ", ".join(f"{column} = ?" for column in updates)
|
|
305
|
+
params = list(updates.values()) + [existing["id"]]
|
|
306
|
+
conn.execute(f"UPDATE followups SET {set_clause} WHERE id = ?", params)
|
|
307
|
+
conn.commit()
|
|
308
|
+
conn.close()
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
"success": True,
|
|
312
|
+
"id": existing["id"],
|
|
313
|
+
"outcome": "matched_existing_followup",
|
|
314
|
+
"similarity": existing.get("_similarity", 1.0),
|
|
315
|
+
"updated_existing": bool(updates),
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _fetch_learning_candidates(category: str = "") -> list[dict]:
|
|
320
|
+
if not NEXO_DB.exists():
|
|
321
|
+
return []
|
|
322
|
+
cols = _table_columns(NEXO_DB, "learnings")
|
|
323
|
+
if not cols:
|
|
324
|
+
return []
|
|
325
|
+
select_fields = ["id", "category", "title", "content", "created_at", "updated_at"]
|
|
326
|
+
for optional in ("reasoning", "prevention", "applies_to", "status", "review_due_at", "last_reviewed_at", "weight", "priority"):
|
|
327
|
+
if optional in cols:
|
|
328
|
+
select_fields.append(optional)
|
|
329
|
+
query = f"SELECT {', '.join(select_fields)} FROM learnings"
|
|
330
|
+
params: list[object] = []
|
|
331
|
+
if category and "category" in cols:
|
|
332
|
+
query += " WHERE category = ?"
|
|
333
|
+
params.append(category)
|
|
334
|
+
query += " ORDER BY COALESCE(updated_at, created_at) DESC LIMIT 240"
|
|
335
|
+
conn = sqlite3.connect(str(NEXO_DB))
|
|
336
|
+
conn.row_factory = sqlite3.Row
|
|
337
|
+
try:
|
|
338
|
+
rows = conn.execute(query, tuple(params)).fetchall()
|
|
339
|
+
finally:
|
|
340
|
+
conn.close()
|
|
341
|
+
return [dict(row) for row in rows]
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _find_learning_match(category: str, title: str, content: str) -> dict | None:
|
|
345
|
+
candidates = []
|
|
346
|
+
new_text = " ".join([str(title or ""), str(content or "")]).strip()
|
|
347
|
+
for row in _fetch_learning_candidates(category):
|
|
348
|
+
existing_text = " ".join([str(row.get("title", "") or ""), str(row.get("content", "") or "")])
|
|
349
|
+
similarity = _text_similarity(new_text, existing_text)
|
|
350
|
+
if similarity < 0.58:
|
|
351
|
+
continue
|
|
352
|
+
contradiction = _looks_contradictory(existing_text, new_text)
|
|
353
|
+
candidates.append({**row, "_similarity": similarity, "_contradiction": contradiction})
|
|
354
|
+
if not candidates:
|
|
355
|
+
return None
|
|
356
|
+
candidates.sort(
|
|
357
|
+
key=lambda item: (item["_contradiction"], item["_similarity"], item.get("updated_at", 0) or item.get("created_at", 0)),
|
|
358
|
+
reverse=True,
|
|
359
|
+
)
|
|
360
|
+
return candidates[0]
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _update_learning_row(learning_id: int, updates: dict[str, object]) -> None:
|
|
364
|
+
if not updates:
|
|
365
|
+
return
|
|
366
|
+
conn = sqlite3.connect(str(NEXO_DB))
|
|
367
|
+
set_clause = ", ".join(f"{column} = ?" for column in updates)
|
|
368
|
+
conn.execute(f"UPDATE learnings SET {set_clause} WHERE id = ?", list(updates.values()) + [learning_id])
|
|
369
|
+
conn.commit()
|
|
370
|
+
conn.close()
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _bump_weight(existing_value, amount: float) -> float:
|
|
374
|
+
try:
|
|
375
|
+
base = float(existing_value or 0)
|
|
376
|
+
except Exception:
|
|
377
|
+
base = 0.0
|
|
378
|
+
return round(min(10.0, base + amount), 2)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _flag_learning_contradiction(existing: dict, category: str, title: str, content: str) -> dict:
|
|
382
|
+
review_description = (
|
|
383
|
+
f"Reconcile contradictory learning in {category or 'general'}: "
|
|
384
|
+
f"review existing learning #{existing.get('id')} ('{existing.get('title', '')}') "
|
|
385
|
+
f"against new Deep Sleep finding '{title}'. Produce one canonical rule, update guardrails, and remove ambiguity."
|
|
386
|
+
)
|
|
387
|
+
followup_result = create_followup(
|
|
388
|
+
description=review_description,
|
|
389
|
+
date="",
|
|
390
|
+
reasoning_note=f"Contradiction detected against learning #{existing.get('id')}: {content[:240]}",
|
|
391
|
+
)
|
|
392
|
+
return {
|
|
393
|
+
"success": followup_result.get("success", False),
|
|
394
|
+
"id": existing.get("id"),
|
|
395
|
+
"outcome": "contradiction_review",
|
|
396
|
+
"similarity": existing.get("_similarity", 0.0),
|
|
397
|
+
"review_followup_id": followup_result.get("id"),
|
|
398
|
+
"followup_result": followup_result,
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
|
|
78
402
|
def add_learning(category: str, title: str, content: str) -> dict:
|
|
79
403
|
"""Add a learning to nexo.db. Returns result dict."""
|
|
80
404
|
if not NEXO_DB.exists():
|
|
81
405
|
return {"success": False, "error": "nexo.db not found"}
|
|
82
406
|
try:
|
|
407
|
+
existing = _find_learning_match(category, title, content)
|
|
408
|
+
if existing:
|
|
409
|
+
similarity = existing.get("_similarity", 0.0)
|
|
410
|
+
if existing.get("_contradiction"):
|
|
411
|
+
return _flag_learning_contradiction(existing, category, title, content)
|
|
412
|
+
|
|
413
|
+
updates: dict[str, object] = {}
|
|
414
|
+
columns = _table_columns(NEXO_DB, "learnings")
|
|
415
|
+
if "updated_at" in columns:
|
|
416
|
+
updates["updated_at"] = datetime.now().timestamp()
|
|
417
|
+
|
|
418
|
+
existing_title = _normalize_text(existing.get("title", ""))
|
|
419
|
+
existing_content = _normalize_text(existing.get("content", ""))
|
|
420
|
+
incoming_title = _normalize_text(title)
|
|
421
|
+
incoming_content = _normalize_text(content)
|
|
422
|
+
|
|
423
|
+
if similarity >= 0.95 and (
|
|
424
|
+
existing_title == incoming_title
|
|
425
|
+
or existing_content == incoming_content
|
|
426
|
+
or incoming_content in existing_content
|
|
427
|
+
or existing_content in incoming_content
|
|
428
|
+
):
|
|
429
|
+
if "weight" in columns:
|
|
430
|
+
updates["weight"] = _bump_weight(existing.get("weight"), 0.1)
|
|
431
|
+
if "last_reviewed_at" in columns:
|
|
432
|
+
updates["last_reviewed_at"] = datetime.now().timestamp()
|
|
433
|
+
if "reasoning" in columns:
|
|
434
|
+
updates["reasoning"] = _append_note(
|
|
435
|
+
existing.get("reasoning", ""),
|
|
436
|
+
f"Reconfirmed by Deep Sleep on {datetime.now().strftime('%Y-%m-%d')}.",
|
|
437
|
+
)
|
|
438
|
+
_update_learning_row(existing["id"], updates)
|
|
439
|
+
return {
|
|
440
|
+
"success": True,
|
|
441
|
+
"id": existing["id"],
|
|
442
|
+
"outcome": "duplicate_learning",
|
|
443
|
+
"similarity": similarity,
|
|
444
|
+
"updated_existing": bool(updates),
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if similarity >= 0.58:
|
|
448
|
+
if "weight" in columns:
|
|
449
|
+
updates["weight"] = _bump_weight(existing.get("weight"), 0.25)
|
|
450
|
+
if "reasoning" in columns:
|
|
451
|
+
updates["reasoning"] = _append_note(
|
|
452
|
+
existing.get("reasoning", ""),
|
|
453
|
+
f"Deep Sleep reinforcement ({datetime.now().strftime('%Y-%m-%d')}): {title}. {content[:240]}",
|
|
454
|
+
)
|
|
455
|
+
elif "content" in columns and content and content not in str(existing.get("content", "")):
|
|
456
|
+
updates["content"] = _append_note(
|
|
457
|
+
existing.get("content", ""),
|
|
458
|
+
f"Reinforced by Deep Sleep: {content[:240]}",
|
|
459
|
+
)
|
|
460
|
+
_update_learning_row(existing["id"], updates)
|
|
461
|
+
return {
|
|
462
|
+
"success": True,
|
|
463
|
+
"id": existing["id"],
|
|
464
|
+
"outcome": "reinforced_learning",
|
|
465
|
+
"similarity": similarity,
|
|
466
|
+
"updated_existing": bool(updates),
|
|
467
|
+
}
|
|
468
|
+
|
|
83
469
|
now = datetime.now().timestamp()
|
|
470
|
+
columns = _table_columns(NEXO_DB, "learnings")
|
|
471
|
+
payload = {
|
|
472
|
+
"category": category,
|
|
473
|
+
"title": title,
|
|
474
|
+
"content": content,
|
|
475
|
+
"created_at": now,
|
|
476
|
+
"updated_at": now,
|
|
477
|
+
}
|
|
478
|
+
if "reasoning" in columns:
|
|
479
|
+
payload["reasoning"] = "Deep Sleep v2 overnight analysis"
|
|
480
|
+
if "status" in columns:
|
|
481
|
+
payload["status"] = "active"
|
|
482
|
+
insert_columns = [column for column in payload if column in columns]
|
|
483
|
+
values = [payload[column] for column in insert_columns]
|
|
484
|
+
|
|
84
485
|
conn = sqlite3.connect(str(NEXO_DB))
|
|
85
486
|
cursor = conn.execute(
|
|
86
|
-
"INSERT INTO learnings (
|
|
87
|
-
|
|
487
|
+
f"INSERT INTO learnings ({', '.join(insert_columns)}) VALUES ({', '.join('?' for _ in insert_columns)})",
|
|
488
|
+
values,
|
|
88
489
|
)
|
|
89
490
|
learning_id = cursor.lastrowid
|
|
90
491
|
conn.commit()
|
|
91
492
|
conn.close()
|
|
92
|
-
return {"success": True, "id": learning_id}
|
|
493
|
+
return {"success": True, "id": learning_id, "outcome": "new_learning"}
|
|
93
494
|
except Exception as e:
|
|
94
495
|
return {"success": False, "error": str(e)}
|
|
95
496
|
|
|
96
497
|
|
|
97
|
-
def create_followup(description: str, date: str = "") -> dict:
|
|
498
|
+
def create_followup(description: str, date: str = "", reasoning_note: str = "") -> dict:
|
|
98
499
|
"""Create a followup in nexo.db. Returns result dict."""
|
|
99
500
|
if not NEXO_DB.exists():
|
|
100
501
|
return {"success": False, "error": "nexo.db not found"}
|
|
101
502
|
try:
|
|
503
|
+
matched = _find_similar_followup(description)
|
|
504
|
+
if matched:
|
|
505
|
+
return _touch_existing_followup(
|
|
506
|
+
matched,
|
|
507
|
+
description=description,
|
|
508
|
+
date=date,
|
|
509
|
+
reasoning_note=reasoning_note or "Deep Sleep matched this followup semantically.",
|
|
510
|
+
)
|
|
511
|
+
|
|
102
512
|
now = datetime.now().timestamp()
|
|
103
513
|
# Generate a deterministic ID
|
|
104
514
|
fid = "NF-DS-" + hashlib.md5(description.encode()).hexdigest()[:8].upper()
|
|
515
|
+
columns = _table_columns(NEXO_DB, "followups")
|
|
516
|
+
payload = {
|
|
517
|
+
"id": fid,
|
|
518
|
+
"description": description,
|
|
519
|
+
"date": date,
|
|
520
|
+
"status": "PENDING",
|
|
521
|
+
"created_at": now,
|
|
522
|
+
"updated_at": now,
|
|
523
|
+
}
|
|
524
|
+
if "reasoning" in columns:
|
|
525
|
+
payload["reasoning"] = reasoning_note or "Deep Sleep v2 overnight analysis"
|
|
526
|
+
if "verification" in columns:
|
|
527
|
+
payload["verification"] = ""
|
|
528
|
+
insert_columns = [column for column in payload if column in columns]
|
|
529
|
+
values = [payload[column] for column in insert_columns]
|
|
530
|
+
|
|
105
531
|
conn = sqlite3.connect(str(NEXO_DB))
|
|
106
532
|
conn.execute(
|
|
107
|
-
"INSERT OR IGNORE INTO followups (
|
|
108
|
-
|
|
533
|
+
f"INSERT OR IGNORE INTO followups ({', '.join(insert_columns)}) VALUES ({', '.join('?' for _ in insert_columns)})",
|
|
534
|
+
values,
|
|
109
535
|
)
|
|
110
536
|
conn.commit()
|
|
111
537
|
conn.close()
|
|
112
|
-
return {"success": True, "id": fid}
|
|
538
|
+
return {"success": True, "id": fid, "outcome": "new_followup"}
|
|
113
539
|
except Exception as e:
|
|
114
540
|
return {"success": False, "error": str(e)}
|
|
115
541
|
|
|
@@ -964,7 +1390,8 @@ def apply_action(action: dict, run_id: str) -> dict:
|
|
|
964
1390
|
elif action_type == "followup_create":
|
|
965
1391
|
result = create_followup(
|
|
966
1392
|
description=content.get("description", content.get("title", "")),
|
|
967
|
-
date=content.get("date", "")
|
|
1393
|
+
date=content.get("date", ""),
|
|
1394
|
+
reasoning_note=content.get("reasoning", content.get("why", "")),
|
|
968
1395
|
)
|
|
969
1396
|
log_entry["status"] = "applied" if result.get("success") else "error"
|
|
970
1397
|
log_entry["details"] = result
|
|
@@ -30,6 +30,15 @@ Synthesize across all sessions:
|
|
|
30
30
|
- Cross-domain connections where an older learning or session sample explains a current issue
|
|
31
31
|
- Topics repeatedly mentioned over time but never formalized into a learning or followup
|
|
32
32
|
- Project pressure that is rising because of repeated diary mentions, open followups, or adverse outcomes
|
|
33
|
+
- For medium/high-severity patterns, propose a concrete fix artifact:
|
|
34
|
+
- script
|
|
35
|
+
- hook
|
|
36
|
+
- checklist
|
|
37
|
+
- validation step
|
|
38
|
+
- workflow change
|
|
39
|
+
- guardrail
|
|
40
|
+
|
|
41
|
+
Do not stop at diagnosis. Turn repeated problems into concrete engineering work.
|
|
33
42
|
|
|
34
43
|
### 2. Morning Agenda
|
|
35
44
|
Generate a prioritized agenda for the next morning:
|
|
@@ -126,6 +135,14 @@ Merge and deduplicate all findings into a final action list. Each action should
|
|
|
126
135
|
- `dedupe_key`: deterministic key for idempotency
|
|
127
136
|
- `content`: the actual data to write
|
|
128
137
|
|
|
138
|
+
When generating `followup_create`, prefer descriptions that start with a concrete verb and include the deliverable:
|
|
139
|
+
- "Add a pre-release validation script ..."
|
|
140
|
+
- "Implement a guard hook that ..."
|
|
141
|
+
- "Create a checklist for ..."
|
|
142
|
+
- "Write a watchdog check that ..."
|
|
143
|
+
|
|
144
|
+
Avoid vague followups that merely restate the diagnosis.
|
|
145
|
+
|
|
129
146
|
## Output Format
|
|
130
147
|
|
|
131
148
|
Return ONLY valid JSON. No markdown code fences. No explanation text.
|
|
@@ -140,6 +157,12 @@ Return ONLY valid JSON. No markdown code fences. No explanation text.
|
|
|
140
157
|
"pattern": "Description of the pattern",
|
|
141
158
|
"sessions": ["session1.jsonl", "session2.jsonl"],
|
|
142
159
|
"severity": "low|medium|high",
|
|
160
|
+
"proposed_fix": {
|
|
161
|
+
"title": "Short concrete fix title",
|
|
162
|
+
"description": "Concrete engineering change to make",
|
|
163
|
+
"deliverable": "script|hook|checklist|workflow|guardrail",
|
|
164
|
+
"confidence": 0.0
|
|
165
|
+
},
|
|
143
166
|
"evidence": [
|
|
144
167
|
{"type": "transcript", "session_id": "...", "message_index": 42, "quote": "..."}
|
|
145
168
|
]
|
|
@@ -14,6 +14,7 @@ import json
|
|
|
14
14
|
import os
|
|
15
15
|
import subprocess
|
|
16
16
|
import sys
|
|
17
|
+
import hashlib
|
|
17
18
|
from datetime import datetime
|
|
18
19
|
from pathlib import Path
|
|
19
20
|
|
|
@@ -28,6 +29,7 @@ if str(NEXO_CODE) not in sys.path:
|
|
|
28
29
|
from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
|
|
29
30
|
|
|
30
31
|
CLAUDE_TIMEOUT = 21600 # 3h safety net (prevents zombie processes)
|
|
32
|
+
ACTION_VERBS = {"add", "implement", "create", "write", "build", "enforce", "automate", "validate", "guard", "fix", "review"}
|
|
31
33
|
|
|
32
34
|
|
|
33
35
|
def extract_json_from_response(text: str) -> dict | None:
|
|
@@ -86,6 +88,96 @@ def collect_skill_runtime_candidates(target_date: str) -> tuple[Path, dict]:
|
|
|
86
88
|
return output_file, payload
|
|
87
89
|
|
|
88
90
|
|
|
91
|
+
def _normalize_action_text(value: str) -> str:
|
|
92
|
+
return " ".join(str(value or "").strip().lower().split())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _looks_concrete_action(text: str) -> bool:
|
|
96
|
+
words = {word.strip(".,:;()[]{}").lower() for word in str(text or "").split()}
|
|
97
|
+
return bool(words & ACTION_VERBS)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _pattern_followup_from_fix(pattern: dict) -> dict | None:
|
|
101
|
+
severity = str(pattern.get("severity", "") or "").lower()
|
|
102
|
+
sessions = pattern.get("sessions", []) or []
|
|
103
|
+
if severity not in {"medium", "high"} and len(sessions) < 2:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
proposed_fix = pattern.get("proposed_fix") or {}
|
|
107
|
+
pattern_text = str(pattern.get("pattern", "") or "").strip()
|
|
108
|
+
title = str(proposed_fix.get("title", "") or "").strip()
|
|
109
|
+
description = str(proposed_fix.get("description", "") or "").strip()
|
|
110
|
+
deliverable = str(proposed_fix.get("deliverable", "") or proposed_fix.get("artifact", "") or "").strip()
|
|
111
|
+
|
|
112
|
+
if title and description:
|
|
113
|
+
if _looks_concrete_action(description):
|
|
114
|
+
followup_description = description
|
|
115
|
+
else:
|
|
116
|
+
followup_description = f"{title}: {description}"
|
|
117
|
+
elif description:
|
|
118
|
+
followup_description = description
|
|
119
|
+
elif title:
|
|
120
|
+
followup_description = title
|
|
121
|
+
elif pattern_text:
|
|
122
|
+
followup_description = (
|
|
123
|
+
f"Implement a concrete guardrail for recurring issue: {pattern_text}. "
|
|
124
|
+
"Deliverable should be a script, hook, checklist, or automated validation that prevents the same failure from repeating."
|
|
125
|
+
)
|
|
126
|
+
else:
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
if deliverable and deliverable.lower() not in followup_description.lower():
|
|
130
|
+
followup_description = f"{followup_description} Deliverable: {deliverable}."
|
|
131
|
+
if not _looks_concrete_action(followup_description):
|
|
132
|
+
followup_description = f"Implement this fix: {followup_description}"
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
"action_type": "followup_create",
|
|
136
|
+
"action_class": "auto_apply" if severity == "high" else "draft_for_morning",
|
|
137
|
+
"confidence": round(max(float(proposed_fix.get("confidence", 0.0) or 0.0), 0.86 if severity == "high" else 0.78), 2),
|
|
138
|
+
"impact": "high" if severity == "high" else "medium",
|
|
139
|
+
"reversibility": "reversible",
|
|
140
|
+
"evidence": pattern.get("evidence", []) or [],
|
|
141
|
+
"dedupe_key": "engineering-fix:" + hashlib.md5(
|
|
142
|
+
_normalize_action_text(followup_description).encode("utf-8")
|
|
143
|
+
).hexdigest()[:16],
|
|
144
|
+
"content": {
|
|
145
|
+
"title": title or f"Engineering fix for: {pattern_text[:90]}",
|
|
146
|
+
"description": followup_description,
|
|
147
|
+
"date": "",
|
|
148
|
+
"reasoning": f"Deep Sleep engineering followup from recurring pattern: {pattern_text}",
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def backfill_engineering_actions(payload: dict) -> dict:
|
|
154
|
+
if not isinstance(payload, dict):
|
|
155
|
+
return payload
|
|
156
|
+
actions = payload.get("actions")
|
|
157
|
+
if not isinstance(actions, list):
|
|
158
|
+
actions = []
|
|
159
|
+
payload["actions"] = actions
|
|
160
|
+
|
|
161
|
+
existing_keys = {str(action.get("dedupe_key", "") or "") for action in actions}
|
|
162
|
+
existing_descriptions = {
|
|
163
|
+
_normalize_action_text(action.get("content", {}).get("description", ""))
|
|
164
|
+
for action in actions
|
|
165
|
+
if isinstance(action, dict)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
for pattern in payload.get("cross_session_patterns", []) or []:
|
|
169
|
+
action = _pattern_followup_from_fix(pattern)
|
|
170
|
+
if not action:
|
|
171
|
+
continue
|
|
172
|
+
description = _normalize_action_text(action["content"]["description"])
|
|
173
|
+
if action["dedupe_key"] in existing_keys or description in existing_descriptions:
|
|
174
|
+
continue
|
|
175
|
+
actions.append(action)
|
|
176
|
+
existing_keys.add(action["dedupe_key"])
|
|
177
|
+
existing_descriptions.add(description)
|
|
178
|
+
return payload
|
|
179
|
+
|
|
180
|
+
|
|
89
181
|
def main():
|
|
90
182
|
target_date = sys.argv[1] if len(sys.argv) > 1 else datetime.now().strftime("%Y-%m-%d")
|
|
91
183
|
|
|
@@ -177,6 +269,8 @@ def main():
|
|
|
177
269
|
print(f"[synthesize] Failed to parse JSON. Raw output saved to {debug_file}", file=sys.stderr)
|
|
178
270
|
sys.exit(1)
|
|
179
271
|
|
|
272
|
+
parsed = backfill_engineering_actions(parsed)
|
|
273
|
+
|
|
180
274
|
# Write synthesis output
|
|
181
275
|
output_file = DEEP_SLEEP_DIR / f"{target_date}-synthesis.json"
|
|
182
276
|
with open(output_file, "w") as f:
|
package/templates/nexo_helper.py
CHANGED
|
@@ -16,6 +16,36 @@ from pathlib import Path
|
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
19
|
+
DEFAULT_ALLOWED_TOOLS = "Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _load_schedule() -> dict:
|
|
23
|
+
schedule_path = NEXO_HOME / "config" / "schedule.json"
|
|
24
|
+
if not schedule_path.is_file():
|
|
25
|
+
return {}
|
|
26
|
+
try:
|
|
27
|
+
return json.loads(schedule_path.read_text())
|
|
28
|
+
except Exception:
|
|
29
|
+
return {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _resolve_automation_backend() -> str:
|
|
33
|
+
data = _load_schedule()
|
|
34
|
+
return str(data.get("automation_backend", "claude_code") or "claude_code")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _load_bootstrap_prompt() -> str:
|
|
38
|
+
backend = _resolve_automation_backend()
|
|
39
|
+
if backend == "codex":
|
|
40
|
+
path = Path.home() / ".codex" / "AGENTS.md"
|
|
41
|
+
else:
|
|
42
|
+
path = Path.home() / ".claude" / "CLAUDE.md"
|
|
43
|
+
if not path.is_file():
|
|
44
|
+
return ""
|
|
45
|
+
try:
|
|
46
|
+
return path.read_text()
|
|
47
|
+
except Exception:
|
|
48
|
+
return ""
|
|
19
49
|
|
|
20
50
|
|
|
21
51
|
def run_nexo(args: list[str]) -> str:
|
|
@@ -57,6 +87,9 @@ def run_automation_text(
|
|
|
57
87
|
model: str = "",
|
|
58
88
|
reasoning_effort: str = "",
|
|
59
89
|
cwd: str = "",
|
|
90
|
+
allowed_tools: str = DEFAULT_ALLOWED_TOOLS,
|
|
91
|
+
append_system_prompt: str = "",
|
|
92
|
+
include_bootstrap: bool = True,
|
|
60
93
|
) -> str:
|
|
61
94
|
"""Run the configured NEXO automation backend and return text output.
|
|
62
95
|
|
|
@@ -75,6 +108,17 @@ def run_automation_text(
|
|
|
75
108
|
cmd.extend(["--reasoning-effort", reasoning_effort])
|
|
76
109
|
if cwd:
|
|
77
110
|
cmd.extend(["--cwd", cwd])
|
|
111
|
+
merged_system_prompt = []
|
|
112
|
+
if include_bootstrap:
|
|
113
|
+
bootstrap = _load_bootstrap_prompt()
|
|
114
|
+
if bootstrap:
|
|
115
|
+
merged_system_prompt.append(bootstrap)
|
|
116
|
+
if append_system_prompt:
|
|
117
|
+
merged_system_prompt.append(append_system_prompt)
|
|
118
|
+
if merged_system_prompt:
|
|
119
|
+
cmd.extend(["--append-system-prompt", "\n\n".join(merged_system_prompt)])
|
|
120
|
+
if allowed_tools:
|
|
121
|
+
cmd.extend(["--allowed-tools", allowed_tools])
|
|
78
122
|
|
|
79
123
|
env = os.environ.copy()
|
|
80
124
|
env.setdefault("NEXO_HOME", str(NEXO_HOME))
|