nexo-brain 7.30.28 → 7.30.30
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/bin/nexo-brain.js +14 -0
- package/package.json +1 -1
- package/src/auto_update.py +16 -0
- package/src/cli.py +13 -0
- package/src/db/_schema.py +19 -1
- package/src/local_context/api.py +112 -0
- package/src/paths.py +2 -2
- package/src/plugins/core_rules.py +180 -116
- package/src/plugins/schedule.py +82 -18
- package/src/rules/core-rules.json +342 -4
- package/src/script_registry.py +279 -10
- package/src/scripts/nexo-backup.sh +6 -1
- package/src/scripts/nexo-cron-wrapper.sh +67 -0
- package/src/scripts/prune_runtime_backups.py +28 -15
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.30.
|
|
3
|
+
"version": "7.30.29",
|
|
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/bin/nexo-brain.js
CHANGED
|
@@ -3403,6 +3403,20 @@ async function runSetup() {
|
|
|
3403
3403
|
if (fs.existsSync(rulesSrc)) {
|
|
3404
3404
|
copyDirRec(rulesSrc, rulesDest);
|
|
3405
3405
|
log(" Rules updated.");
|
|
3406
|
+
const rulesSyncPython = findVenvPython(NEXO_HOME) || "python3";
|
|
3407
|
+
const rulesSync = spawnSync(rulesSyncPython, [
|
|
3408
|
+
"-c",
|
|
3409
|
+
"import os,sys; sys.path.insert(0, os.environ['NEXO_CODE']); from plugins.core_rules import _sync_rules_from_json; print(_sync_rules_from_json().get('active_total', 0))"
|
|
3410
|
+
], {
|
|
3411
|
+
cwd: srcDir,
|
|
3412
|
+
env: { ...process.env, NEXO_HOME, NEXO_CODE: srcDir, PYTHONPATH: srcDir },
|
|
3413
|
+
encoding: "utf8",
|
|
3414
|
+
timeout: 30000,
|
|
3415
|
+
});
|
|
3416
|
+
if (rulesSync.status !== 0) {
|
|
3417
|
+
throw new Error(`Core rules registry sync failed: ${rulesSync.stderr || rulesSync.stdout || "unknown error"}`);
|
|
3418
|
+
}
|
|
3419
|
+
log(` Core rules registry synced (${String(rulesSync.stdout || "").trim() || "0"} active).`);
|
|
3406
3420
|
}
|
|
3407
3421
|
|
|
3408
3422
|
// Update crons (manifest.json + sync.py — needed by catchup & watchdog)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.30.
|
|
3
|
+
"version": "7.30.30",
|
|
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/auto_update.py
CHANGED
|
@@ -2593,6 +2593,7 @@ def _run_db_migrations() -> bool:
|
|
|
2593
2593
|
applied = run_migrations(conn)
|
|
2594
2594
|
if applied > 0:
|
|
2595
2595
|
_log(f"Applied {applied} DB migration(s)")
|
|
2596
|
+
_sync_core_rules_registry()
|
|
2596
2597
|
# Plan Consolidado F1 — one-shot legacy email config migration.
|
|
2597
2598
|
# After m46 adds the table, operators installed pre-v6.4.0 still
|
|
2598
2599
|
# keep their data inside ~/.nexo/nexo-email/config.json. If the
|
|
@@ -2620,6 +2621,21 @@ def _run_db_migrations() -> bool:
|
|
|
2620
2621
|
return False
|
|
2621
2622
|
|
|
2622
2623
|
|
|
2624
|
+
def _sync_core_rules_registry() -> None:
|
|
2625
|
+
"""Keep packaged product-core rules installed in the Brain DB."""
|
|
2626
|
+
try:
|
|
2627
|
+
from plugins.core_rules import _sync_rules_from_json
|
|
2628
|
+
result = _sync_rules_from_json()
|
|
2629
|
+
if result.get("status") == "applied":
|
|
2630
|
+
_log(
|
|
2631
|
+
"Core rules registry synced: "
|
|
2632
|
+
f"v{result.get('version_from')} -> v{result.get('version_to')} "
|
|
2633
|
+
f"({result.get('active_total')} active)"
|
|
2634
|
+
)
|
|
2635
|
+
except Exception as exc:
|
|
2636
|
+
raise RuntimeError(f"core rules registry sync failed: {exc}") from exc
|
|
2637
|
+
|
|
2638
|
+
|
|
2623
2639
|
def _maybe_migrate_legacy_email_config() -> None:
|
|
2624
2640
|
"""F1 auto-migrator — idempotent. Runs the helper script the first
|
|
2625
2641
|
time after v6.4.0 lands on an existing runtime."""
|
package/src/cli.py
CHANGED
|
@@ -1287,17 +1287,27 @@ def _agents_set_schedule(args):
|
|
|
1287
1287
|
|
|
1288
1288
|
interval_seconds = None
|
|
1289
1289
|
daily_at = None
|
|
1290
|
+
schedule_freq = None
|
|
1291
|
+
schedule_at = None
|
|
1292
|
+
schedule_day = None
|
|
1290
1293
|
if getattr(args, "every_minutes", None) is not None:
|
|
1291
1294
|
interval_seconds = int(args.every_minutes) * 60
|
|
1292
1295
|
elif getattr(args, "every_seconds", None) is not None:
|
|
1293
1296
|
interval_seconds = int(args.every_seconds)
|
|
1294
1297
|
elif getattr(args, "daily_at", None):
|
|
1295
1298
|
daily_at = str(args.daily_at).strip()
|
|
1299
|
+
elif getattr(args, "schedule_freq", None):
|
|
1300
|
+
schedule_freq = str(args.schedule_freq).strip()
|
|
1301
|
+
schedule_at = str(getattr(args, "schedule_at", "") or "").strip()
|
|
1302
|
+
schedule_day = getattr(args, "schedule_day", None)
|
|
1296
1303
|
|
|
1297
1304
|
result = set_agent_schedule(
|
|
1298
1305
|
args.name,
|
|
1299
1306
|
interval_seconds=interval_seconds,
|
|
1300
1307
|
daily_at=daily_at,
|
|
1308
|
+
schedule_freq=schedule_freq,
|
|
1309
|
+
schedule_at=schedule_at,
|
|
1310
|
+
schedule_day=schedule_day,
|
|
1301
1311
|
clear=bool(getattr(args, "reset", False)),
|
|
1302
1312
|
)
|
|
1303
1313
|
if args.json:
|
|
@@ -4160,7 +4170,10 @@ def main():
|
|
|
4160
4170
|
agents_schedule_group.add_argument("--every-minutes", type=int, help="Run the agent every N minutes")
|
|
4161
4171
|
agents_schedule_group.add_argument("--every-seconds", type=int, help="Run the agent every N seconds")
|
|
4162
4172
|
agents_schedule_group.add_argument("--daily-at", type=str, help="Run the agent every day at HH:MM or HH:MM:weekday")
|
|
4173
|
+
agents_schedule_group.add_argument("--schedule-freq", choices=["daily", "weekly", "monthly", "every_n_days"], help="Run the agent on an anchored cadence")
|
|
4163
4174
|
agents_schedule_group.add_argument("--reset", action="store_true", help="Clear the agent schedule")
|
|
4175
|
+
agents_schedule_p.add_argument("--schedule-at", type=str, help="Anchored schedule time HH:MM")
|
|
4176
|
+
agents_schedule_p.add_argument("--schedule-day", type=int, help="Weekday 0-6, month day 1-28, or every N days")
|
|
4164
4177
|
agents_schedule_p.add_argument("--json", action="store_true", help="JSON output")
|
|
4165
4178
|
|
|
4166
4179
|
agents_run_p = agents_sub.add_parser("run", help="Run an agent now")
|
package/src/db/_schema.py
CHANGED
|
@@ -288,7 +288,13 @@ def _m15_core_rules_tables(conn):
|
|
|
288
288
|
type TEXT NOT NULL DEFAULT 'advisory',
|
|
289
289
|
added_in TEXT DEFAULT '',
|
|
290
290
|
removed_in TEXT DEFAULT NULL,
|
|
291
|
-
is_active INTEGER NOT NULL DEFAULT 1
|
|
291
|
+
is_active INTEGER NOT NULL DEFAULT 1,
|
|
292
|
+
source_artifact TEXT DEFAULT '',
|
|
293
|
+
source_anchor TEXT DEFAULT '',
|
|
294
|
+
content_hash TEXT DEFAULT '',
|
|
295
|
+
protected INTEGER NOT NULL DEFAULT 1,
|
|
296
|
+
severity TEXT DEFAULT 'critical',
|
|
297
|
+
replacement_rule_id TEXT DEFAULT NULL
|
|
292
298
|
)
|
|
293
299
|
""")
|
|
294
300
|
conn.execute("""
|
|
@@ -3063,6 +3069,17 @@ def _m80_opportunity_orchestrator(conn):
|
|
|
3063
3069
|
_migrate_add_index(conn, "idx_nexo_authorizations_scope", "nexo_action_authorizations", "scope, allowed_action_class")
|
|
3064
3070
|
|
|
3065
3071
|
|
|
3072
|
+
def _m81_core_rules_product_metadata(conn):
|
|
3073
|
+
"""Add product-core provenance and protection metadata to core_rules."""
|
|
3074
|
+
_migrate_add_column(conn, "core_rules", "source_artifact", "TEXT DEFAULT ''")
|
|
3075
|
+
_migrate_add_column(conn, "core_rules", "source_anchor", "TEXT DEFAULT ''")
|
|
3076
|
+
_migrate_add_column(conn, "core_rules", "content_hash", "TEXT DEFAULT ''")
|
|
3077
|
+
_migrate_add_column(conn, "core_rules", "protected", "INTEGER NOT NULL DEFAULT 1")
|
|
3078
|
+
_migrate_add_column(conn, "core_rules", "severity", "TEXT DEFAULT 'critical'")
|
|
3079
|
+
_migrate_add_column(conn, "core_rules", "replacement_rule_id", "TEXT DEFAULT NULL")
|
|
3080
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_core_rules_protected ON core_rules(protected, is_active)")
|
|
3081
|
+
|
|
3082
|
+
|
|
3066
3083
|
MIGRATIONS = [
|
|
3067
3084
|
(1, "learnings_columns", _m1_learnings_columns),
|
|
3068
3085
|
(2, "followups_reasoning", _m2_followups_reasoning),
|
|
@@ -3144,6 +3161,7 @@ MIGRATIONS = [
|
|
|
3144
3161
|
(78, "operational_closure_plane", _m78_operational_closure_plane),
|
|
3145
3162
|
(79, "operational_closure_links_readiness", _m79_operational_closure_links_readiness),
|
|
3146
3163
|
(80, "opportunity_orchestrator", _m80_opportunity_orchestrator),
|
|
3164
|
+
(81, "core_rules_product_metadata", _m81_core_rules_product_metadata),
|
|
3147
3165
|
]
|
|
3148
3166
|
|
|
3149
3167
|
|
package/src/local_context/api.py
CHANGED
|
@@ -43,6 +43,8 @@ DEFAULT_MAX_JOB_ATTEMPTS = int(os.environ.get("NEXO_LOCAL_INDEX_MAX_JOB_ATTEMPTS
|
|
|
43
43
|
DEFAULT_SQLITE_BUSY_RETRY_ATTEMPTS = int(os.environ.get("NEXO_LOCAL_CONTEXT_BUSY_RETRY_ATTEMPTS", "5") or "5")
|
|
44
44
|
DEFAULT_SQLITE_BUSY_RETRY_DELAY_SECONDS = float(os.environ.get("NEXO_LOCAL_CONTEXT_BUSY_RETRY_DELAY_SECONDS", "0.35") or "0.35")
|
|
45
45
|
DEFAULT_HYGIENE_QUICK_SCAN_LIMIT = int(os.environ.get("NEXO_LOCAL_INDEX_HYGIENE_QUICK_SCAN_LIMIT", "5000") or "5000")
|
|
46
|
+
LOCAL_CONTEXT_DEFAULT_MAX_DB_BYTES = 60 * 1024 ** 3
|
|
47
|
+
LOCAL_CONTEXT_DEFAULT_MIN_FREE_BYTES = 5 * 1024 ** 3
|
|
46
48
|
INITIAL_INDEX_COMPLETE_KEY = "initial_index_complete"
|
|
47
49
|
INITIAL_INDEX_STARTED_AT_KEY = "initial_index_started_at"
|
|
48
50
|
PERFORMANCE_PROFILE_KEY = "performance_profile"
|
|
@@ -1845,6 +1847,73 @@ def _is_paused_conn(conn) -> bool:
|
|
|
1845
1847
|
return _get_state_conn(conn, "paused", "0") == "1"
|
|
1846
1848
|
|
|
1847
1849
|
|
|
1850
|
+
def _local_context_storage_bytes(db_path: Path | None = None) -> int:
|
|
1851
|
+
db = Path(db_path or local_context_db_path())
|
|
1852
|
+
total = 0
|
|
1853
|
+
for candidate in (db, Path(str(db) + "-wal"), Path(str(db) + "-shm")):
|
|
1854
|
+
try:
|
|
1855
|
+
total += int(candidate.stat().st_size)
|
|
1856
|
+
except OSError:
|
|
1857
|
+
pass
|
|
1858
|
+
return total
|
|
1859
|
+
|
|
1860
|
+
|
|
1861
|
+
def _local_context_disk_budget() -> dict:
|
|
1862
|
+
db_path = Path(local_context_db_path())
|
|
1863
|
+
max_bytes = paths.parse_size_bytes(
|
|
1864
|
+
os.environ.get("NEXO_LOCAL_CONTEXT_MAX_DB_BYTES"),
|
|
1865
|
+
default=LOCAL_CONTEXT_DEFAULT_MAX_DB_BYTES,
|
|
1866
|
+
)
|
|
1867
|
+
min_free_bytes = paths.parse_size_bytes(
|
|
1868
|
+
os.environ.get("NEXO_LOCAL_CONTEXT_MIN_FREE_BYTES"),
|
|
1869
|
+
default=LOCAL_CONTEXT_DEFAULT_MIN_FREE_BYTES,
|
|
1870
|
+
)
|
|
1871
|
+
db_bytes = _local_context_storage_bytes(db_path)
|
|
1872
|
+
try:
|
|
1873
|
+
probe = db_path.parent if db_path.parent.exists() else paths.memory_dir()
|
|
1874
|
+
free_bytes = int(shutil.disk_usage(str(probe)).free)
|
|
1875
|
+
except Exception:
|
|
1876
|
+
free_bytes = None
|
|
1877
|
+
|
|
1878
|
+
reason = ""
|
|
1879
|
+
if max_bytes > 0 and db_bytes > max_bytes:
|
|
1880
|
+
reason = "local_context_db_too_large"
|
|
1881
|
+
elif min_free_bytes > 0 and free_bytes is not None and free_bytes < min_free_bytes:
|
|
1882
|
+
reason = "disk_free_below_floor"
|
|
1883
|
+
return {
|
|
1884
|
+
"ok": not reason,
|
|
1885
|
+
"paused": bool(reason),
|
|
1886
|
+
"reason": reason,
|
|
1887
|
+
"db_path": str(db_path),
|
|
1888
|
+
"db_bytes": db_bytes,
|
|
1889
|
+
"max_bytes": max_bytes,
|
|
1890
|
+
"free_bytes": free_bytes,
|
|
1891
|
+
"min_free_bytes": min_free_bytes,
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
|
|
1895
|
+
def enforce_local_context_disk_budget() -> dict:
|
|
1896
|
+
budget = _local_context_disk_budget()
|
|
1897
|
+
if budget["ok"]:
|
|
1898
|
+
return budget
|
|
1899
|
+
try:
|
|
1900
|
+
_set_state("paused", "1")
|
|
1901
|
+
_set_state("pause_reason", str(budget["reason"]))
|
|
1902
|
+
log_event(
|
|
1903
|
+
"warn",
|
|
1904
|
+
"index_paused_disk_budget",
|
|
1905
|
+
"Local memory indexing paused to protect disk space",
|
|
1906
|
+
reason=budget["reason"],
|
|
1907
|
+
db_bytes=budget["db_bytes"],
|
|
1908
|
+
max_bytes=budget["max_bytes"],
|
|
1909
|
+
free_bytes=budget["free_bytes"],
|
|
1910
|
+
min_free_bytes=budget["min_free_bytes"],
|
|
1911
|
+
)
|
|
1912
|
+
except Exception as exc:
|
|
1913
|
+
budget["pause_error"] = type(exc).__name__
|
|
1914
|
+
return budget
|
|
1915
|
+
|
|
1916
|
+
|
|
1848
1917
|
def _allow_explicit_blocked_root(path: str) -> bool:
|
|
1849
1918
|
# Test and controlled diagnostics may explicitly index a temporary fixture
|
|
1850
1919
|
# root while production root discovery still skips temp/system trees.
|
|
@@ -3332,6 +3401,33 @@ def run_once(
|
|
|
3332
3401
|
live_dir_limit: int | None = None,
|
|
3333
3402
|
live_file_limit: int | None = None,
|
|
3334
3403
|
) -> dict:
|
|
3404
|
+
disk_budget = enforce_local_context_disk_budget()
|
|
3405
|
+
if disk_budget.get("paused"):
|
|
3406
|
+
config = performance_config()
|
|
3407
|
+
return {
|
|
3408
|
+
"ok": True,
|
|
3409
|
+
"paused": True,
|
|
3410
|
+
"disk_budget": disk_budget,
|
|
3411
|
+
"initial_scan": {
|
|
3412
|
+
"complete": False,
|
|
3413
|
+
"mode": "paused",
|
|
3414
|
+
"pending_roots": 0,
|
|
3415
|
+
"total_roots": 0,
|
|
3416
|
+
"checkpoint_count": 0,
|
|
3417
|
+
},
|
|
3418
|
+
"initial_index_complete": False,
|
|
3419
|
+
"live": {"ok": True, "paused": True, "assets": {}, "dirs": {}},
|
|
3420
|
+
"scan": {"ok": True, "paused": True, "roots": 0, "seen": 0, "changed": 0, "errors": 0, "partial": False},
|
|
3421
|
+
"jobs": {"ok": True, "paused": True, "processed": 0, "failed": 0},
|
|
3422
|
+
"performance": {
|
|
3423
|
+
"profile": config["profile"],
|
|
3424
|
+
"scan_limit": 0,
|
|
3425
|
+
"process_limit": 0,
|
|
3426
|
+
"live_asset_limit": 0,
|
|
3427
|
+
"live_dir_limit": 0,
|
|
3428
|
+
"live_file_limit": 0,
|
|
3429
|
+
},
|
|
3430
|
+
}
|
|
3335
3431
|
if _get_state("privacy_hygiene_v2", "0") != "1":
|
|
3336
3432
|
local_index_privacy_hygiene(fix=True)
|
|
3337
3433
|
_set_state("privacy_hygiene_v2", "1")
|
|
@@ -3832,6 +3928,21 @@ def _status_from_conn(conn, *, readonly: bool = False) -> dict:
|
|
|
3832
3928
|
service["state"] = "paused" if paused else ("attention" if problem else ("idle" if active_jobs == 0 and initial_index_complete else "indexing"))
|
|
3833
3929
|
performance = performance_config(conn=conn)
|
|
3834
3930
|
problems = _problem_rows(conn)
|
|
3931
|
+
disk_budget = _local_context_disk_budget()
|
|
3932
|
+
if not disk_budget["ok"]:
|
|
3933
|
+
problems.insert(0, {
|
|
3934
|
+
"user_message": "Local memory indexing is paused to protect disk space",
|
|
3935
|
+
"message_key": "local_context.disk_budget.paused",
|
|
3936
|
+
"recommended_action": "Review local memory size and free disk space",
|
|
3937
|
+
"recommended_action_key": "local_context.disk_budget.review",
|
|
3938
|
+
"technical_detail": json_dumps(disk_budget),
|
|
3939
|
+
"support_code": "LOCAL_CONTEXT_DISK_BUDGET",
|
|
3940
|
+
"severity": "warning",
|
|
3941
|
+
"retryable": True,
|
|
3942
|
+
"path": disk_budget["db_path"],
|
|
3943
|
+
"phase": "storage",
|
|
3944
|
+
"created_at": now(),
|
|
3945
|
+
})
|
|
3835
3946
|
if problem:
|
|
3836
3947
|
problems.insert(0, {
|
|
3837
3948
|
"user_message": problem["user_message"],
|
|
@@ -3881,6 +3992,7 @@ def _status_from_conn(conn, *, readonly: bool = False) -> dict:
|
|
|
3881
3992
|
"performance_profile": performance["profile"],
|
|
3882
3993
|
},
|
|
3883
3994
|
"performance": performance,
|
|
3995
|
+
"disk_budget": disk_budget,
|
|
3884
3996
|
"initial_scan": initial_scan,
|
|
3885
3997
|
"initial_index_complete": bool(initial_index_complete),
|
|
3886
3998
|
"volumes": volumes,
|
package/src/paths.py
CHANGED
|
@@ -515,8 +515,8 @@ def aggressive_runtime_backup_prune(
|
|
|
515
515
|
) -> dict:
|
|
516
516
|
"""Escalate NEXO-owned backup pruning before any user-facing disk alert.
|
|
517
517
|
|
|
518
|
-
Escalation never targets protected business/
|
|
519
|
-
|
|
518
|
+
Escalation never targets protected business/weekly restore classes. Hourly
|
|
519
|
+
DB dumps are pruned only down to their configured restore floor.
|
|
520
520
|
"""
|
|
521
521
|
root = Path(backups_root or backups_dir())
|
|
522
522
|
floor = int(min_free_bytes if min_free_bytes is not None else backup_min_free_bytes())
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Core Rules plugin — query and manage versioned behavioral rules."""
|
|
2
2
|
|
|
3
|
+
import hashlib
|
|
3
4
|
import json
|
|
4
5
|
import os
|
|
5
6
|
|
|
@@ -9,54 +10,167 @@ def _get_db():
|
|
|
9
10
|
return get_db()
|
|
10
11
|
|
|
11
12
|
|
|
12
|
-
def
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
except Exception:
|
|
19
|
-
# Table doesn't exist yet — create it
|
|
20
|
-
conn.execute("""CREATE TABLE IF NOT EXISTS core_rules (
|
|
21
|
-
id TEXT PRIMARY KEY, category TEXT NOT NULL, rule TEXT NOT NULL,
|
|
22
|
-
why TEXT NOT NULL, importance INTEGER NOT NULL DEFAULT 3,
|
|
23
|
-
type TEXT NOT NULL DEFAULT 'advisory', added_in TEXT DEFAULT '',
|
|
24
|
-
removed_in TEXT DEFAULT NULL, is_active INTEGER NOT NULL DEFAULT 1)""")
|
|
25
|
-
conn.execute("""CREATE TABLE IF NOT EXISTS core_rules_version (
|
|
26
|
-
id INTEGER PRIMARY KEY, version TEXT NOT NULL, updated_at TEXT NOT NULL)""")
|
|
27
|
-
conn.execute("INSERT OR IGNORE INTO core_rules_version (id, version, updated_at) VALUES (1, '0.0.0', datetime('now'))")
|
|
28
|
-
conn.commit()
|
|
29
|
-
count = 0
|
|
30
|
-
if count > 0:
|
|
31
|
-
return
|
|
13
|
+
def _rules_file_path() -> str:
|
|
14
|
+
return os.path.join(
|
|
15
|
+
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
16
|
+
"rules",
|
|
17
|
+
"core-rules.json",
|
|
18
|
+
)
|
|
32
19
|
|
|
33
|
-
rules_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
34
|
-
"rules", "core-rules.json")
|
|
35
|
-
if not os.path.exists(rules_file):
|
|
36
|
-
print(f"[core_rules] WARNING: {rules_file} not found, skipping seed", file=sys.stderr)
|
|
37
|
-
return
|
|
38
20
|
|
|
21
|
+
def _load_rules_data() -> dict:
|
|
22
|
+
with open(_rules_file_path()) as f:
|
|
23
|
+
return json.load(f)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _rule_hash(rule: dict, category: str) -> str:
|
|
27
|
+
payload = {
|
|
28
|
+
"category": category,
|
|
29
|
+
"id": rule.get("id", ""),
|
|
30
|
+
"rule": rule.get("rule", ""),
|
|
31
|
+
"why": rule.get("why", ""),
|
|
32
|
+
"importance": rule.get("importance", 0),
|
|
33
|
+
"type": rule.get("type", ""),
|
|
34
|
+
"source_artifact": rule.get("source_artifact", ""),
|
|
35
|
+
"source_anchor": rule.get("source_anchor", ""),
|
|
36
|
+
}
|
|
37
|
+
raw = json.dumps(payload, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
|
|
38
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _ensure_schema(conn):
|
|
42
|
+
conn.execute("""CREATE TABLE IF NOT EXISTS core_rules (
|
|
43
|
+
id TEXT PRIMARY KEY, category TEXT NOT NULL, rule TEXT NOT NULL,
|
|
44
|
+
why TEXT NOT NULL, importance INTEGER NOT NULL DEFAULT 3,
|
|
45
|
+
type TEXT NOT NULL DEFAULT 'advisory', added_in TEXT DEFAULT '',
|
|
46
|
+
removed_in TEXT DEFAULT NULL, is_active INTEGER NOT NULL DEFAULT 1,
|
|
47
|
+
source_artifact TEXT DEFAULT '', source_anchor TEXT DEFAULT '',
|
|
48
|
+
content_hash TEXT DEFAULT '', protected INTEGER NOT NULL DEFAULT 1,
|
|
49
|
+
severity TEXT DEFAULT 'critical', replacement_rule_id TEXT DEFAULT NULL)""")
|
|
50
|
+
conn.execute("""CREATE TABLE IF NOT EXISTS core_rules_version (
|
|
51
|
+
id INTEGER PRIMARY KEY, version TEXT NOT NULL, updated_at TEXT NOT NULL)""")
|
|
52
|
+
for column, ddl in (
|
|
53
|
+
("source_artifact", "TEXT DEFAULT ''"),
|
|
54
|
+
("source_anchor", "TEXT DEFAULT ''"),
|
|
55
|
+
("content_hash", "TEXT DEFAULT ''"),
|
|
56
|
+
("protected", "INTEGER NOT NULL DEFAULT 1"),
|
|
57
|
+
("severity", "TEXT DEFAULT 'critical'"),
|
|
58
|
+
("replacement_rule_id", "TEXT DEFAULT NULL"),
|
|
59
|
+
):
|
|
60
|
+
try:
|
|
61
|
+
existing = {row[1] for row in conn.execute("PRAGMA table_info(core_rules)").fetchall()}
|
|
62
|
+
if column not in existing:
|
|
63
|
+
conn.execute(f"ALTER TABLE core_rules ADD COLUMN {column} {ddl}")
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
conn.execute("INSERT OR IGNORE INTO core_rules_version (id, version, updated_at) VALUES (1, '0.0.0', datetime('now'))")
|
|
67
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_core_rules_category ON core_rules(category)")
|
|
68
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_core_rules_active ON core_rules(is_active)")
|
|
69
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_core_rules_protected ON core_rules(protected, is_active)")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _flatten_rules(data: dict) -> dict[str, dict]:
|
|
73
|
+
version = data["_meta"]["version"]
|
|
74
|
+
rules = {}
|
|
75
|
+
for cat_key, cat in data["categories"].items():
|
|
76
|
+
for rule in cat["rules"]:
|
|
77
|
+
severity = rule.get("severity") or ("critical" if rule.get("type") == "blocking" and int(rule.get("importance") or 0) >= 5 else "high")
|
|
78
|
+
rules[rule["id"]] = {
|
|
79
|
+
**rule,
|
|
80
|
+
"category": cat_key,
|
|
81
|
+
"added_in": rule.get("added_in", version),
|
|
82
|
+
"content_hash": _rule_hash(rule, cat_key),
|
|
83
|
+
"protected": 0 if rule.get("protected") is False else 1,
|
|
84
|
+
"severity": severity,
|
|
85
|
+
"source_artifact": rule.get("source_artifact", "core-rules.json"),
|
|
86
|
+
"source_anchor": rule.get("source_anchor", rule["id"]),
|
|
87
|
+
"replacement_rule_id": rule.get("replacement_rule_id"),
|
|
88
|
+
}
|
|
89
|
+
return rules
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _sync_rules_from_json(conn=None, dry_run: bool = False) -> dict:
|
|
93
|
+
conn = conn or _get_db()
|
|
94
|
+
_ensure_schema(conn)
|
|
95
|
+
data = _load_rules_data()
|
|
96
|
+
new_version = data["_meta"]["version"]
|
|
97
|
+
json_rules = _flatten_rules(data)
|
|
98
|
+
|
|
99
|
+
current_version_row = conn.execute("SELECT version FROM core_rules_version WHERE id = 1").fetchone()
|
|
100
|
+
current_version = current_version_row[0] if current_version_row else "0.0.0"
|
|
101
|
+
db_rows = {
|
|
102
|
+
row["id"]: dict(row)
|
|
103
|
+
for row in conn.execute("SELECT * FROM core_rules WHERE is_active = 1").fetchall()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
added = set(json_rules) - set(db_rows)
|
|
107
|
+
removed = set(db_rows) - set(json_rules)
|
|
108
|
+
changed = {
|
|
109
|
+
rid
|
|
110
|
+
for rid in set(json_rules) & set(db_rows)
|
|
111
|
+
if (db_rows[rid].get("content_hash") or "") != json_rules[rid]["content_hash"]
|
|
112
|
+
or current_version != new_version
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
result = {
|
|
116
|
+
"version_from": current_version,
|
|
117
|
+
"version_to": new_version,
|
|
118
|
+
"added": sorted(added),
|
|
119
|
+
"removed": sorted(removed),
|
|
120
|
+
"changed": sorted(changed),
|
|
121
|
+
"active_total": len(json_rules),
|
|
122
|
+
"dry_run": dry_run,
|
|
123
|
+
}
|
|
124
|
+
if dry_run:
|
|
125
|
+
return result
|
|
126
|
+
|
|
127
|
+
for rid in sorted(added | changed):
|
|
128
|
+
r = json_rules[rid]
|
|
129
|
+
conn.execute(
|
|
130
|
+
"""INSERT OR REPLACE INTO core_rules
|
|
131
|
+
(id, category, rule, why, importance, type, added_in, removed_in, is_active,
|
|
132
|
+
source_artifact, source_anchor, content_hash, protected, severity, replacement_rule_id)
|
|
133
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 1, ?, ?, ?, ?, ?, ?)""",
|
|
134
|
+
(
|
|
135
|
+
r["id"],
|
|
136
|
+
r["category"],
|
|
137
|
+
r["rule"],
|
|
138
|
+
r["why"],
|
|
139
|
+
r["importance"],
|
|
140
|
+
r["type"],
|
|
141
|
+
r["added_in"],
|
|
142
|
+
r["source_artifact"],
|
|
143
|
+
r["source_anchor"],
|
|
144
|
+
r["content_hash"],
|
|
145
|
+
r["protected"],
|
|
146
|
+
r["severity"],
|
|
147
|
+
r["replacement_rule_id"],
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
for rid in sorted(removed):
|
|
152
|
+
replacement = db_rows.get(rid, {}).get("replacement_rule_id")
|
|
153
|
+
conn.execute(
|
|
154
|
+
"UPDATE core_rules SET is_active = 0, removed_in = ?, replacement_rule_id = COALESCE(?, replacement_rule_id) WHERE id = ?",
|
|
155
|
+
(new_version, replacement, rid),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
conn.execute("UPDATE core_rules_version SET version = ?, updated_at = datetime('now') WHERE id = 1", (new_version,))
|
|
159
|
+
conn.commit()
|
|
160
|
+
result["status"] = "up_to_date" if not (added or removed or changed or current_version != new_version) else "applied"
|
|
161
|
+
return result
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _sync_if_needed():
|
|
165
|
+
"""Keep installed DB rules aligned with packaged product-core JSON."""
|
|
166
|
+
import sys
|
|
167
|
+
if not os.path.exists(_rules_file_path()):
|
|
168
|
+
print(f"[core_rules] WARNING: {_rules_file_path()} not found, skipping sync", file=sys.stderr)
|
|
169
|
+
return
|
|
39
170
|
try:
|
|
40
|
-
|
|
41
|
-
data = json.load(f)
|
|
42
|
-
|
|
43
|
-
version = data["_meta"]["version"]
|
|
44
|
-
loaded = 0
|
|
45
|
-
for cat_key, cat in data["categories"].items():
|
|
46
|
-
for rule in cat["rules"]:
|
|
47
|
-
conn.execute(
|
|
48
|
-
"""INSERT OR REPLACE INTO core_rules (id, category, rule, why, importance, type, added_in)
|
|
49
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
|
50
|
-
(rule["id"], cat_key, rule["rule"], rule["why"],
|
|
51
|
-
rule["importance"], rule["type"], rule.get("added_in", version))
|
|
52
|
-
)
|
|
53
|
-
loaded += 1
|
|
54
|
-
|
|
55
|
-
conn.execute("UPDATE core_rules_version SET version = ?, updated_at = datetime('now') WHERE id = 1", (version,))
|
|
56
|
-
conn.commit()
|
|
57
|
-
print(f"[core_rules] Seeded {loaded} rules (v{version})", file=sys.stderr)
|
|
171
|
+
_sync_rules_from_json()
|
|
58
172
|
except Exception as e:
|
|
59
|
-
print(f"[core_rules] ERROR
|
|
173
|
+
print(f"[core_rules] ERROR syncing rules: {e}", file=sys.stderr)
|
|
60
174
|
|
|
61
175
|
|
|
62
176
|
def handle_rules_check(area: str = "", importance_min: int = 0) -> str:
|
|
@@ -70,21 +184,24 @@ def handle_rules_check(area: str = "", importance_min: int = 0) -> str:
|
|
|
70
184
|
Maps to categories: code→execution+integrity, delegation→delegation, etc.
|
|
71
185
|
importance_min: Minimum importance level (1-5, default 0 = all rules)
|
|
72
186
|
"""
|
|
73
|
-
|
|
187
|
+
_sync_if_needed()
|
|
74
188
|
conn = _get_db()
|
|
75
189
|
|
|
76
190
|
area_to_categories = {
|
|
77
|
-
"code": ("integrity", "execution"),
|
|
78
|
-
"edit": ("integrity", "execution"),
|
|
191
|
+
"code": ("integrity", "execution", "product_core", "bootstrap_contract"),
|
|
192
|
+
"edit": ("integrity", "execution", "product_core", "bootstrap_contract"),
|
|
79
193
|
"delegation": ("delegation",),
|
|
80
194
|
"delegate": ("delegation",),
|
|
81
195
|
"subagent": ("delegation",),
|
|
82
|
-
"communication": ("communication",),
|
|
83
|
-
"respond": ("communication",),
|
|
84
|
-
"memory": ("memory",),
|
|
85
|
-
"learn": ("memory",),
|
|
86
|
-
"proactivity": ("proactivity",),
|
|
87
|
-
"protect": ("proactivity",),
|
|
196
|
+
"communication": ("communication", "product_core"),
|
|
197
|
+
"respond": ("communication", "product_core"),
|
|
198
|
+
"memory": ("memory", "product_core", "bootstrap_contract"),
|
|
199
|
+
"learn": ("memory", "product_core"),
|
|
200
|
+
"proactivity": ("proactivity", "product_core"),
|
|
201
|
+
"protect": ("proactivity", "product_core"),
|
|
202
|
+
"support": ("product_core",),
|
|
203
|
+
"capability": ("product_core",),
|
|
204
|
+
"bootstrap": ("bootstrap_contract",),
|
|
88
205
|
}
|
|
89
206
|
|
|
90
207
|
where = "WHERE is_active = 1"
|
|
@@ -146,7 +263,7 @@ def handle_rules_list(
|
|
|
146
263
|
limit: int = 0,
|
|
147
264
|
) -> str:
|
|
148
265
|
"""List all core rules with their status, grouped by category."""
|
|
149
|
-
|
|
266
|
+
_sync_if_needed()
|
|
150
267
|
conn = _get_db()
|
|
151
268
|
|
|
152
269
|
ver = conn.execute("SELECT version FROM core_rules_version WHERE id = 1").fetchone()
|
|
@@ -202,75 +319,22 @@ def handle_rules_migrate(dry_run: bool = False) -> str:
|
|
|
202
319
|
Args:
|
|
203
320
|
dry_run: If True, show what would change without applying
|
|
204
321
|
"""
|
|
205
|
-
|
|
206
|
-
rules_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
207
|
-
"rules", "core-rules.json")
|
|
208
|
-
if not os.path.exists(rules_file):
|
|
322
|
+
if not os.path.exists(_rules_file_path()):
|
|
209
323
|
return "ERROR: core-rules.json not found"
|
|
210
|
-
|
|
211
|
-
with open(rules_file) as f:
|
|
212
|
-
data = json.load(f)
|
|
213
|
-
|
|
214
|
-
new_version = data["_meta"]["version"]
|
|
215
|
-
ver = conn.execute("SELECT version FROM core_rules_version WHERE id = 1").fetchone()
|
|
216
|
-
current_version = ver[0] if ver else "0.0.0"
|
|
217
|
-
|
|
218
|
-
# Collect all rule IDs from JSON
|
|
219
|
-
json_ids = set()
|
|
220
|
-
json_rules = {}
|
|
221
|
-
for cat_key, cat in data["categories"].items():
|
|
222
|
-
for rule in cat["rules"]:
|
|
223
|
-
json_ids.add(rule["id"])
|
|
224
|
-
json_rules[rule["id"]] = {**rule, "category": cat_key}
|
|
225
|
-
|
|
226
|
-
# Collect active IDs from DB
|
|
227
|
-
db_ids = set()
|
|
228
|
-
for r in conn.execute("SELECT id FROM core_rules WHERE is_active = 1").fetchall():
|
|
229
|
-
db_ids.add(r[0])
|
|
230
|
-
|
|
231
|
-
added = json_ids - db_ids
|
|
232
|
-
removed = db_ids - json_ids
|
|
233
|
-
unchanged = json_ids & db_ids
|
|
324
|
+
result = _sync_rules_from_json(dry_run=dry_run)
|
|
234
325
|
|
|
235
326
|
lines = [
|
|
236
|
-
f"RULES MIGRATION: v{
|
|
237
|
-
f" Added: {len(added)} — {', '.join(
|
|
238
|
-
f" Removed: {len(removed)} — {', '.join(
|
|
239
|
-
f"
|
|
327
|
+
f"RULES MIGRATION: v{result['version_from']} → v{result['version_to']}",
|
|
328
|
+
f" Added: {len(result['added'])} — {', '.join(result['added']) if result['added'] else 'none'}",
|
|
329
|
+
f" Removed: {len(result['removed'])} — {', '.join(result['removed']) if result['removed'] else 'none'}",
|
|
330
|
+
f" Changed: {len(result['changed'])} — {', '.join(result['changed']) if result['changed'] else 'none'}",
|
|
331
|
+
f" Active total: {result['active_total']}",
|
|
240
332
|
]
|
|
241
333
|
|
|
242
334
|
if dry_run:
|
|
243
335
|
lines.append(" Mode: DRY RUN (no changes applied)")
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
# Apply additions
|
|
247
|
-
for rid in added:
|
|
248
|
-
r = json_rules[rid]
|
|
249
|
-
conn.execute(
|
|
250
|
-
"""INSERT OR REPLACE INTO core_rules (id, category, rule, why, importance, type, added_in, is_active)
|
|
251
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, 1)""",
|
|
252
|
-
(r["id"], r["category"], r["rule"], r["why"], r["importance"], r["type"], r.get("added_in", new_version))
|
|
253
|
-
)
|
|
254
|
-
|
|
255
|
-
# Apply removals (soft delete)
|
|
256
|
-
for rid in removed:
|
|
257
|
-
conn.execute(
|
|
258
|
-
"UPDATE core_rules SET is_active = 0, removed_in = ? WHERE id = ?",
|
|
259
|
-
(new_version, rid)
|
|
260
|
-
)
|
|
261
|
-
|
|
262
|
-
# Update existing rules (content might have changed)
|
|
263
|
-
for rid in unchanged:
|
|
264
|
-
r = json_rules[rid]
|
|
265
|
-
conn.execute(
|
|
266
|
-
"UPDATE core_rules SET rule = ?, why = ?, importance = ?, type = ?, category = ? WHERE id = ?",
|
|
267
|
-
(r["rule"], r["why"], r["importance"], r["type"], r["category"], rid)
|
|
268
|
-
)
|
|
269
|
-
|
|
270
|
-
conn.execute("UPDATE core_rules_version SET version = ?, updated_at = datetime('now') WHERE id = 1", (new_version,))
|
|
271
|
-
conn.commit()
|
|
272
|
-
|
|
273
|
-
lines.append(" Status: APPLIED")
|
|
336
|
+
else:
|
|
337
|
+
lines.append(f" Status: {str(result.get('status') or 'APPLIED').upper()}")
|
|
274
338
|
return "\n".join(lines)
|
|
275
339
|
|
|
276
340
|
|