nexo-brain 2.1.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- package/bin/nexo-brain.js +53 -26
- package/package.json +1 -1
- package/scripts/migrate-to-unified 2.sh +813 -0
- package/scripts/migrate-v1.5-to-v1.6 2.py +778 -0
- package/scripts/migrate-v1.7-to-v1.8 2.py +214 -0
- package/scripts/migrate-v1.7-to-v1.8.py +2 -2
- package/scripts/nexo-preflight.sh +236 -0
- package/scripts/pre-commit-check 2.sh +55 -0
- package/src/__pycache__/auto_close_sessions.cpython-314.pyc +0 -0
- package/src/__pycache__/auto_update.cpython-310.pyc +0 -0
- package/src/__pycache__/hnsw_index.cpython-310.pyc +0 -0
- package/src/__pycache__/hnsw_index.cpython-314.pyc +0 -0
- package/src/__pycache__/kg_populate.cpython-310.pyc +0 -0
- package/src/__pycache__/knowledge_graph.cpython-310.pyc +0 -0
- package/src/__pycache__/plugin_loader.cpython-310.pyc +0 -0
- package/src/__pycache__/plugin_loader.cpython-314.pyc +0 -0
- package/src/__pycache__/tools_coordination.cpython-310.pyc +0 -0
- package/src/__pycache__/tools_credentials.cpython-310.pyc +0 -0
- package/src/__pycache__/tools_learnings.cpython-310.pyc +0 -0
- package/src/__pycache__/tools_menu.cpython-310.pyc +0 -0
- package/src/__pycache__/tools_reminders.cpython-310.pyc +0 -0
- package/src/__pycache__/tools_reminders_crud.cpython-310.pyc +0 -0
- package/src/__pycache__/tools_sessions.cpython-310.pyc +0 -0
- package/src/__pycache__/tools_task_history.cpython-310.pyc +0 -0
- package/src/auto_close_sessions 2.py +159 -0
- package/src/auto_update 2.py +634 -0
- package/src/auto_update.py +25 -0
- package/src/claim_graph 2.py +323 -0
- package/src/cognitive/__init__ 2.py +62 -0
- package/src/cognitive/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/cognitive/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/cognitive/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/cognitive/__pycache__/_core.cpython-310.pyc +0 -0
- package/src/cognitive/__pycache__/_core.cpython-312.pyc +0 -0
- package/src/cognitive/__pycache__/_core.cpython-314.pyc +0 -0
- package/src/cognitive/__pycache__/_decay.cpython-310.pyc +0 -0
- package/src/cognitive/__pycache__/_decay.cpython-312.pyc +0 -0
- package/src/cognitive/__pycache__/_decay.cpython-314.pyc +0 -0
- package/src/cognitive/__pycache__/_ingest.cpython-310.pyc +0 -0
- package/src/cognitive/__pycache__/_ingest.cpython-312.pyc +0 -0
- package/src/cognitive/__pycache__/_ingest.cpython-314.pyc +0 -0
- package/src/cognitive/__pycache__/_memory.cpython-310.pyc +0 -0
- package/src/cognitive/__pycache__/_memory.cpython-312.pyc +0 -0
- package/src/cognitive/__pycache__/_memory.cpython-314.pyc +0 -0
- package/src/cognitive/__pycache__/_search.cpython-310.pyc +0 -0
- package/src/cognitive/__pycache__/_search.cpython-312.pyc +0 -0
- package/src/cognitive/__pycache__/_search.cpython-314.pyc +0 -0
- package/src/cognitive/__pycache__/_trust.cpython-310.pyc +0 -0
- package/src/cognitive/__pycache__/_trust.cpython-312.pyc +0 -0
- package/src/cognitive/__pycache__/_trust.cpython-314.pyc +0 -0
- package/src/cognitive/_core 2.py +567 -0
- package/src/cognitive/_decay 2.py +382 -0
- package/src/cognitive/_ingest 2.py +892 -0
- package/src/cognitive/_memory 2.py +912 -0
- package/src/cognitive/_search 2.py +949 -0
- package/src/cognitive/_trust 2.py +464 -0
- package/src/cognitive/_trust.py +10 -36
- package/src/crons/__pycache__/sync.cpython-314.pyc +0 -0
- package/src/crons/manifest 2.json +106 -0
- package/src/crons/manifest.json +6 -13
- package/src/crons/sync 2.py +217 -0
- package/src/crons/sync.py +151 -6
- package/src/dashboard/__init__ 2.py +0 -0
- package/src/dashboard/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/dashboard/__pycache__/app.cpython-310.pyc +0 -0
- package/src/dashboard/app 2.py +789 -0
- package/src/db/__init__ 2.py +89 -0
- package/src/db/__init__.py +13 -0
- package/src/db/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/db/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/db/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/db/__pycache__/_core.cpython-310.pyc +0 -0
- package/src/db/__pycache__/_core.cpython-312.pyc +0 -0
- package/src/db/__pycache__/_core.cpython-314.pyc +0 -0
- package/src/db/__pycache__/_credentials.cpython-310.pyc +0 -0
- package/src/db/__pycache__/_credentials.cpython-312.pyc +0 -0
- package/src/db/__pycache__/_credentials.cpython-314.pyc +0 -0
- package/src/db/__pycache__/_cron_runs.cpython-310.pyc +0 -0
- package/src/db/__pycache__/_cron_runs.cpython-314.pyc +0 -0
- package/src/db/__pycache__/_entities.cpython-310.pyc +0 -0
- package/src/db/__pycache__/_entities.cpython-312.pyc +0 -0
- package/src/db/__pycache__/_entities.cpython-314.pyc +0 -0
- package/src/db/__pycache__/_episodic.cpython-310.pyc +0 -0
- package/src/db/__pycache__/_episodic.cpython-312.pyc +0 -0
- package/src/db/__pycache__/_episodic.cpython-314.pyc +0 -0
- package/src/db/__pycache__/_evolution.cpython-310.pyc +0 -0
- package/src/db/__pycache__/_evolution.cpython-312.pyc +0 -0
- package/src/db/__pycache__/_evolution.cpython-314.pyc +0 -0
- package/src/db/__pycache__/_fts.cpython-310.pyc +0 -0
- package/src/db/__pycache__/_fts.cpython-312.pyc +0 -0
- package/src/db/__pycache__/_fts.cpython-314.pyc +0 -0
- package/src/db/__pycache__/_learnings.cpython-310.pyc +0 -0
- package/src/db/__pycache__/_learnings.cpython-312.pyc +0 -0
- package/src/db/__pycache__/_learnings.cpython-314.pyc +0 -0
- package/src/db/__pycache__/_reminders.cpython-310.pyc +0 -0
- package/src/db/__pycache__/_reminders.cpython-312.pyc +0 -0
- package/src/db/__pycache__/_reminders.cpython-314.pyc +0 -0
- package/src/db/__pycache__/_schema.cpython-310.pyc +0 -0
- package/src/db/__pycache__/_schema.cpython-312.pyc +0 -0
- package/src/db/__pycache__/_schema.cpython-314.pyc +0 -0
- package/src/db/__pycache__/_sessions.cpython-310.pyc +0 -0
- package/src/db/__pycache__/_sessions.cpython-312.pyc +0 -0
- package/src/db/__pycache__/_sessions.cpython-314.pyc +0 -0
- package/src/db/__pycache__/_skills.cpython-310.pyc +0 -0
- package/src/db/__pycache__/_skills.cpython-312.pyc +0 -0
- package/src/db/__pycache__/_skills.cpython-314.pyc +0 -0
- package/src/db/__pycache__/_tasks.cpython-310.pyc +0 -0
- package/src/db/__pycache__/_tasks.cpython-312.pyc +0 -0
- package/src/db/__pycache__/_tasks.cpython-314.pyc +0 -0
- package/src/db/_core 2.py +417 -0
- package/src/db/_credentials 2.py +124 -0
- package/src/db/_cron_runs.py +74 -0
- package/src/db/_entities 2.py +178 -0
- package/src/db/_episodic 2.py +738 -0
- package/src/db/_episodic.py +40 -6
- package/src/db/_evolution 2.py +54 -0
- package/src/db/_fts 2.py +406 -0
- package/src/db/_learnings 2.py +168 -0
- package/src/db/_reminders 2.py +338 -0
- package/src/db/_schema 2.py +364 -0
- package/src/db/_schema.py +64 -0
- package/src/db/_sessions 2.py +300 -0
- package/src/db/_skills.py +514 -0
- package/src/db/_tasks 2.py +91 -0
- package/src/evolution_cycle 2.py +266 -0
- package/src/hnsw_index 2.py +254 -0
- package/src/hooks/auto_capture 2.py +208 -0
- package/src/hooks/caffeinate-guard 2.sh +8 -0
- package/src/hooks/capture-session 2.sh +21 -0
- package/src/hooks/capture-session.sh +2 -0
- package/src/hooks/capture-tool-logs 2.sh +127 -0
- package/src/hooks/capture-tool-logs.sh +3 -2
- package/src/hooks/daily-briefing-check 2.sh +33 -0
- package/src/hooks/inbox-hook 2.sh +76 -0
- package/src/hooks/inbox-hook.sh +3 -2
- package/src/hooks/post-compact 2.sh +148 -0
- package/src/hooks/post-compact.sh +1 -1
- package/src/hooks/pre-compact 2.sh +151 -0
- package/src/hooks/pre-compact.sh +1 -1
- package/src/hooks/session-start 2.sh +268 -0
- package/src/hooks/session-start.sh +6 -3
- package/src/hooks/session-stop 2.sh +140 -0
- package/src/hooks/session-stop.sh +14 -102
- package/src/kg_populate 2.py +290 -0
- package/src/knowledge_graph 2.py +257 -0
- package/src/maintenance 2.py +59 -0
- package/src/migrate_embeddings 2.py +122 -0
- package/src/plugin_loader 2.py +202 -0
- package/src/plugins/__init__ 2.py +0 -0
- package/src/plugins/__pycache__/__init__ 2.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/plugins/__pycache__/adaptive_mode 2.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/adaptive_mode.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/adaptive_mode.cpython-314.pyc +0 -0
- package/src/plugins/__pycache__/agents 2.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/agents.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/artifact_registry 2.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/artifact_registry.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/backup 2.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/backup.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/cognitive_memory 2.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/cognitive_memory.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/core_rules 2.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/core_rules.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/cortex 2.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/cortex.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/entities 2.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/entities.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/episodic_memory 2.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/episodic_memory.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/evolution 2.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/evolution.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/guard 2.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/guard.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/knowledge_graph_tools 2.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/knowledge_graph_tools.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/preferences 2.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/preferences.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/schedule.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/schedule.cpython-314.pyc +0 -0
- package/src/plugins/__pycache__/skills.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/skills.cpython-314.pyc +0 -0
- package/src/plugins/__pycache__/update 2.cpython-310.pyc +0 -0
- package/src/plugins/__pycache__/update.cpython-310.pyc +0 -0
- package/src/plugins/adaptive_mode 2.py +805 -0
- package/src/plugins/agents 2.py +52 -0
- package/src/plugins/artifact_registry 2.py +450 -0
- package/src/plugins/backup 2.py +104 -0
- package/src/plugins/cognitive_memory 2.py +564 -0
- package/src/plugins/core_rules 2.py +252 -0
- package/src/plugins/cortex 2.py +299 -0
- package/src/plugins/entities 2.py +67 -0
- package/src/plugins/episodic_memory 2.py +533 -0
- package/src/plugins/episodic_memory.py +5 -3
- package/src/plugins/evolution 2.py +115 -0
- package/src/plugins/guard 2.py +746 -0
- package/src/plugins/knowledge_graph_tools 2.py +105 -0
- package/src/plugins/preferences 2.py +47 -0
- package/src/plugins/schedule.py +212 -0
- package/src/plugins/skills.py +264 -0
- package/src/plugins/update 2.py +256 -0
- package/src/requirements 2.txt +12 -0
- package/src/rules/__init__ 2.py +0 -0
- package/src/rules/core-rules 2.json +331 -0
- package/src/rules/migrate 2.py +207 -0
- package/src/scripts/__pycache__/nexo-auto-update.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-catchup.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-cognitive-decay.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-daily-self-audit.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-evolution-run.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-followup-hygiene.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-immune.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-install.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-learning-housekeep.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-learning-validator.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-migrate.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-postmortem-consolidator.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-pre-commit.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-proactive-dashboard.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-reflection.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-runtime-preflight.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-send-email.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-send-reply.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-sleep.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-synthesis.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-watchdog-smoke.cpython-314.pyc +0 -0
- package/src/scripts/check-context 2.py +264 -0
- package/src/scripts/deep-sleep/apply_findings.py +168 -8
- package/src/scripts/deep-sleep/collect.py +33 -11
- package/src/scripts/deep-sleep/extract-prompt.md +38 -0
- package/src/scripts/deep-sleep/extract.py +80 -8
- package/src/scripts/deep-sleep/synthesize-prompt.md +59 -2
- package/src/scripts/deep-sleep/synthesize.py +3 -1
- package/src/scripts/nexo-auto-update 2.py +6 -0
- package/src/scripts/nexo-backup 2.sh +25 -0
- package/src/scripts/nexo-brain-activation 2.sh +140 -0
- package/src/scripts/nexo-catchup 2.py +242 -0
- package/src/scripts/nexo-catchup.py +65 -29
- package/src/scripts/nexo-cognitive-decay 2.py +182 -0
- package/src/scripts/nexo-cron-wrapper.sh +53 -0
- package/src/scripts/nexo-daily-self-audit 2.py +552 -0
- package/src/scripts/nexo-daily-self-audit.py +4 -2
- package/src/scripts/nexo-deep-sleep 2.sh +97 -0
- package/src/scripts/nexo-deep-sleep.sh +66 -77
- package/src/scripts/nexo-evolution-run 2.py +597 -0
- package/src/scripts/nexo-evolution-run.py +13 -0
- package/src/scripts/nexo-followup-hygiene 2.py +112 -0
- package/src/scripts/nexo-immune 2.py +927 -0
- package/src/scripts/nexo-inbox-hook 2.sh +74 -0
- package/src/scripts/nexo-install 2.py +6 -0
- package/src/scripts/nexo-learning-housekeep 2.py +245 -0
- package/src/scripts/nexo-learning-housekeep.py +156 -1
- package/src/scripts/nexo-learning-validator 2.py +207 -0
- package/src/scripts/nexo-learning-validator.py +19 -0
- package/src/scripts/nexo-migrate 2.py +232 -0
- package/src/scripts/nexo-postmortem-consolidator 2.py +421 -0
- package/src/scripts/nexo-postmortem-consolidator.py +3 -2
- package/src/scripts/nexo-pre-commit 2.py +120 -0
- package/src/scripts/nexo-prevent-sleep 2.sh +29 -0
- package/src/scripts/nexo-proactive-dashboard 2.py +345 -0
- package/src/scripts/nexo-reflection 2.py +253 -0
- package/src/scripts/nexo-runtime-preflight 2.py +274 -0
- package/src/scripts/nexo-send-email 2.py +25 -0
- package/src/scripts/nexo-send-reply 2.py +178 -0
- package/src/scripts/nexo-sleep 2.py +592 -0
- package/src/scripts/nexo-sleep.py +16 -11
- package/src/scripts/nexo-snapshot-restore 2.sh +35 -0
- package/src/scripts/nexo-synthesis 2.py +253 -0
- package/src/scripts/nexo-synthesis.py +46 -3
- package/src/scripts/nexo-tcc-approve 2.sh +79 -0
- package/src/scripts/nexo-update 2.sh +161 -0
- package/src/scripts/nexo-watchdog 2.sh +878 -0
- package/src/scripts/nexo-watchdog-smoke 2.py +119 -0
- package/src/scripts/nexo-watchdog.sh +72 -19
- package/src/server 2.py +733 -0
- package/src/server.py +11 -2
- package/src/storage_router 2.py +32 -0
- package/src/tools_coordination 2.py +102 -0
- package/src/tools_credentials 2.py +68 -0
- package/src/tools_learnings 2.py +220 -0
- package/src/tools_menu 2.py +227 -0
- package/src/tools_reminders 2.py +86 -0
- package/src/tools_reminders_crud 2.py +159 -0
- package/src/tools_reminders_crud.py +7 -0
- package/src/tools_sessions 2.py +476 -0
- package/src/tools_task_history 2.py +57 -0
- package/templates/CLAUDE.md 2.template +63 -0
- package/templates/openclaw 2.json +13 -0
- package/tests/__init__ 2.py +0 -0
- package/tests/conftest 2.py +71 -0
- package/tests/test_cognitive 2.py +205 -0
- package/tests/test_knowledge_graph 2.py +140 -0
- package/tests/test_migrations 2.py +137 -0
- package/src/scripts/deep-sleep/__pycache__/extract.cpython-314.pyc +0 -0
- /package/src/scripts/{nexo-github-monitor.py → nexo-github-monitor 2.py} +0 -0
|
@@ -1,27 +1,21 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
# NEXO Memory Stop Hook (
|
|
2
|
+
# NEXO Memory Stop Hook (v8 — non-blocking, approve always)
|
|
3
3
|
#
|
|
4
|
-
# v5
|
|
5
|
-
# v6
|
|
6
|
-
# v7
|
|
7
|
-
#
|
|
4
|
+
# v5: used "approve" + systemMessage — AI never processed post-mortem.
|
|
5
|
+
# v6: used "block" — but blocked ALL sessions including trivial ones.
|
|
6
|
+
# v7: detects trivial sessions (<5 tool calls) and approves immediately.
|
|
7
|
+
# v8: NEVER blocks. The Stop hook fires after EVERY Claude response (not just
|
|
8
|
+
# session close), so blocking causes mid-conversation interruptions.
|
|
9
|
+
# Post-mortem is now handled by:
|
|
10
|
+
# 1. Claude detecting closing intent (any language) → diary inline
|
|
11
|
+
# 2. auto_close_sessions.py → promotes draft for orphan sessions
|
|
8
12
|
#
|
|
9
|
-
#
|
|
10
|
-
|
|
11
|
-
# → APPROVE immediately, no post-mortem needed
|
|
12
|
-
#
|
|
13
|
-
# Non-trivial session:
|
|
14
|
-
# 1. User closes → hook checks flag → not found → BLOCK
|
|
15
|
-
# 2. AI executes post-mortem → creates flag
|
|
16
|
-
# 3. User closes again → hook sees flag → APPROVE
|
|
17
|
-
set -euo pipefail
|
|
13
|
+
# This hook only refreshes the diary draft with latest data (best-effort).
|
|
14
|
+
set -uo pipefail
|
|
18
15
|
|
|
19
16
|
NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
|
|
20
|
-
FLAG_FILE="$NEXO_HOME/operations/.postmortem-complete"
|
|
21
|
-
TODAY=$(date +%Y-%m-%d)
|
|
22
|
-
TOOL_LOG="$NEXO_HOME/operations/tool-logs/${TODAY}.jsonl"
|
|
23
17
|
|
|
24
|
-
#
|
|
18
|
+
# Refresh diary draft with latest changes/decisions (best-effort)
|
|
25
19
|
python3 -c "
|
|
26
20
|
import sys, json, os
|
|
27
21
|
nexo_home = os.environ.get('NEXO_HOME', os.path.expanduser('~/.nexo'))
|
|
@@ -50,91 +44,9 @@ for s in sessions:
|
|
|
50
44
|
)
|
|
51
45
|
" 2>/dev/null || true
|
|
52
46
|
|
|
53
|
-
#
|
|
54
|
-
|
|
55
|
-
# A session with <5 tool calls (excluding Read/Grep/Glob/Bash) is trivial
|
|
56
|
-
SESSION_START_TS="$NEXO_HOME/operations/.session-start-ts"
|
|
57
|
-
|
|
58
|
-
# 0.5. Detect non-interactive (claude -p) sessions — skip post-mortem entirely
|
|
59
|
-
# SessionStart hook writes .session-start-ts. If missing or stale (>30 min),
|
|
60
|
-
# this is likely a -p script session — approve immediately.
|
|
61
|
-
# Also skip if NEXO_HEADLESS=1 is set (explicit headless mode for scripts).
|
|
62
|
-
if [ "${NEXO_HEADLESS:-}" = "1" ] || [ ! -f "$SESSION_START_TS" ] || [ "$(($(date +%s) - $(cat "$SESSION_START_TS" 2>/dev/null || echo 0)))" -gt 1800 ]; then
|
|
63
|
-
cat << 'HOOKEOF'
|
|
64
|
-
{
|
|
65
|
-
"decision": "approve"
|
|
66
|
-
}
|
|
67
|
-
HOOKEOF
|
|
68
|
-
exit 0
|
|
69
|
-
fi
|
|
70
|
-
SESSION_START=0
|
|
71
|
-
if [ -f "$SESSION_START_TS" ]; then
|
|
72
|
-
SESSION_START=$(cat "$SESSION_START_TS" 2>/dev/null || echo "0")
|
|
73
|
-
fi
|
|
74
|
-
|
|
75
|
-
TOOL_COUNT=0
|
|
76
|
-
if [ -f "$TOOL_LOG" ]; then
|
|
77
|
-
TOOL_COUNT=$(python3 -c "
|
|
78
|
-
import json, sys, os
|
|
79
|
-
session_start = float(os.environ.get('SESSION_START', '0'))
|
|
80
|
-
count = 0
|
|
81
|
-
for line in open('$TOOL_LOG'):
|
|
82
|
-
try:
|
|
83
|
-
d = json.loads(line)
|
|
84
|
-
# Only count tools from THIS session (after session-start-ts)
|
|
85
|
-
ts = d.get('timestamp', '')
|
|
86
|
-
if ts and session_start > 0:
|
|
87
|
-
from datetime import datetime
|
|
88
|
-
try:
|
|
89
|
-
entry_ts = datetime.fromisoformat(ts.replace('Z', '+00:00')).timestamp()
|
|
90
|
-
if entry_ts < session_start:
|
|
91
|
-
continue
|
|
92
|
-
except:
|
|
93
|
-
pass
|
|
94
|
-
t = d.get('tool_name', '')
|
|
95
|
-
if t and t not in ('Read', 'Grep', 'Glob', 'Bash', 'ToolSearch'):
|
|
96
|
-
count += 1
|
|
97
|
-
except:
|
|
98
|
-
pass
|
|
99
|
-
print(count)
|
|
100
|
-
" 2>/dev/null || echo "0")
|
|
101
|
-
fi
|
|
102
|
-
|
|
103
|
-
# Trivial session → approve immediately, no buffer writing, skip post-mortem
|
|
104
|
-
if [ "$TOOL_COUNT" -lt 5 ]; then
|
|
105
|
-
cat << 'HOOKEOF'
|
|
106
|
-
{
|
|
107
|
-
"decision": "approve"
|
|
108
|
-
}
|
|
109
|
-
HOOKEOF
|
|
110
|
-
exit 0
|
|
111
|
-
fi
|
|
112
|
-
|
|
113
|
-
# 2. Non-trivial session — check if post-mortem was already completed
|
|
114
|
-
# Flag has NO TTL — it persists until SessionStart cleans it up next session.
|
|
115
|
-
POSTMORTEM_DONE=false
|
|
116
|
-
if [ -f "$FLAG_FILE" ]; then
|
|
117
|
-
POSTMORTEM_DONE=true
|
|
118
|
-
fi
|
|
119
|
-
|
|
120
|
-
if [ "$POSTMORTEM_DONE" = true ]; then
|
|
121
|
-
# Post-mortem was done — allow session to close
|
|
122
|
-
# NOTE: do NOT delete flag here — SessionStart cleans it up next session
|
|
123
|
-
cat << 'HOOKEOF'
|
|
47
|
+
# Always approve — never interrupt the conversation
|
|
48
|
+
cat << 'HOOKEOF'
|
|
124
49
|
{
|
|
125
50
|
"decision": "approve"
|
|
126
51
|
}
|
|
127
52
|
HOOKEOF
|
|
128
|
-
else
|
|
129
|
-
# Post-mortem NOT done — BLOCK session close and inject instructions
|
|
130
|
-
cat << 'HOOKEOF'
|
|
131
|
-
{
|
|
132
|
-
"decision": "block",
|
|
133
|
-
"reason": "STOP HOOK — MANDATORY POST-MORTEM before ending (do NOT ask permission, do NOT skip):\n\n## 1. SELF-CRITIQUE (MANDATORY — write to session diary)\nAnswer these questions in the self_critique field of nexo_session_diary_write:\n- Did the user have to ask me for something I should have detected or done on my own?\n- Did I wait for the user to tell me something I could have verified proactively?\n- Are there systems/states I can check next session without being asked?\n- Did I repeat an error that already had a registered learning?\n- What would I do differently if I repeated this session?\nIf any answer is YES — write the specific rule that would prevent repetition.\nIf the session was flawless, write 'No self-critique — clean session.'\n\n## 2. SESSION BUFFER\nIf the session was NOT trivial, append ONE JSON line to $NEXO_HOME/brain/session_buffer.jsonl:\n{\"ts\":\"YYYY-MM-DDTHH:MM:SS\",\"tasks\":[...],\"decisions\":[...],\"user_patterns\":[...],\"files_modified\":[...],\"errors_resolved\":[...],\"self_critique\":\"short summary\",\"mood\":\"focused|impatient|exploratory|frustrated|satisfied|neutral\",\"source\":\"claude\"}\n\n## 3. FOLLOWUPS\nIf there were deploys/cron changes/fixes — nexo_followup_create with verification date.\n\n## 4. PROACTIVE SEEDS\nWhat can I leave prepared so the next session starts doing useful work without the user asking?\n\n## 5. MARK COMPLETE\nWhen ALL of the above is done, run:\nbash -c 'mkdir -p $NEXO_HOME/operations && date +%s > $NEXO_HOME/operations/.postmortem-complete'\nThe user will close again and the hook will approve.\n\nIMPORTANT: Do NOT say goodbye, do NOT say goodnight or any farewell. Just execute the steps and mark complete."
|
|
134
|
-
}
|
|
135
|
-
HOOKEOF
|
|
136
|
-
fi
|
|
137
|
-
|
|
138
|
-
# 3. Session buffer fallback REMOVED (v8)
|
|
139
|
-
# The old hook-fallback was 86% noise. Session diary (written by Claude during
|
|
140
|
-
# post-mortem) is the only source of truth now. No more buffer writing.
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""NEXO KG Auto-Population — backfill from nexo.db + incremental hooks."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sqlite3
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import knowledge_graph as kg
|
|
9
|
+
from db import get_db
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ─── helpers ────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
def _cognitive_db():
|
|
15
|
+
"""Direct cognitive.db connection (for somatic_markers)."""
|
|
16
|
+
nexo_home = os.environ.get("NEXO_HOME", os.path.expanduser("~/.nexo"))
|
|
17
|
+
data_dir = os.path.join(nexo_home, "data")
|
|
18
|
+
os.makedirs(data_dir, exist_ok=True)
|
|
19
|
+
path = os.path.join(data_dir, "cognitive.db")
|
|
20
|
+
conn = sqlite3.connect(path)
|
|
21
|
+
conn.row_factory = sqlite3.Row
|
|
22
|
+
return conn
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _parse_files(files_str: str) -> list[str]:
|
|
26
|
+
"""Extract individual file paths from a comma/newline-separated string."""
|
|
27
|
+
if not files_str:
|
|
28
|
+
return []
|
|
29
|
+
parts = [p.strip() for p in files_str.replace("\n", ",").split(",")]
|
|
30
|
+
return [p for p in parts if p]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ─── backfill functions ──────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
def backfill_entities() -> int:
|
|
36
|
+
"""Read entities table → create entity nodes in KG."""
|
|
37
|
+
db = get_db()
|
|
38
|
+
rows = db.execute("SELECT id, name, type, value, notes FROM entities").fetchall()
|
|
39
|
+
count = 0
|
|
40
|
+
for row in rows:
|
|
41
|
+
props = {}
|
|
42
|
+
if row["value"]:
|
|
43
|
+
props["value"] = row["value"]
|
|
44
|
+
if row["notes"]:
|
|
45
|
+
props["notes"] = row["notes"]
|
|
46
|
+
kg.upsert_node(
|
|
47
|
+
node_type="entity",
|
|
48
|
+
node_ref=f"entity:{row['id']}",
|
|
49
|
+
label=row["name"],
|
|
50
|
+
properties={"entity_type": row["type"], **props},
|
|
51
|
+
)
|
|
52
|
+
count += 1
|
|
53
|
+
return count
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def backfill_learnings() -> int:
|
|
57
|
+
"""Read learnings → create learning nodes + file/area edges."""
|
|
58
|
+
db = get_db()
|
|
59
|
+
rows = db.execute(
|
|
60
|
+
"SELECT id, category, title, applies_to FROM learnings WHERE status != 'deleted'"
|
|
61
|
+
).fetchall()
|
|
62
|
+
count = 0
|
|
63
|
+
for row in rows:
|
|
64
|
+
learning_ref = f"learning:{row['id']}"
|
|
65
|
+
kg.upsert_node(
|
|
66
|
+
node_type="learning",
|
|
67
|
+
node_ref=learning_ref,
|
|
68
|
+
label=row["title"] or f"Learning #{row['id']}",
|
|
69
|
+
properties={"category": row["category"]},
|
|
70
|
+
)
|
|
71
|
+
# edge: learning → category/area
|
|
72
|
+
if row["category"]:
|
|
73
|
+
kg.upsert_edge(
|
|
74
|
+
source_type="learning", source_ref=learning_ref,
|
|
75
|
+
relation="belongs_to",
|
|
76
|
+
target_type="area", target_ref=f"area:{row['category']}",
|
|
77
|
+
weight=1.0,
|
|
78
|
+
)
|
|
79
|
+
# edge: learning → file (from applies_to)
|
|
80
|
+
applies = row["applies_to"] or ""
|
|
81
|
+
for fpath in _parse_files(applies):
|
|
82
|
+
if fpath:
|
|
83
|
+
kg.upsert_edge(
|
|
84
|
+
source_type="learning", source_ref=learning_ref,
|
|
85
|
+
relation="applies_to_file",
|
|
86
|
+
target_type="file", target_ref=f"file:{fpath}",
|
|
87
|
+
weight=0.8,
|
|
88
|
+
)
|
|
89
|
+
count += 1
|
|
90
|
+
return count
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def backfill_changes() -> int:
|
|
94
|
+
"""Read change_log → create file nodes + file→area edges."""
|
|
95
|
+
db = get_db()
|
|
96
|
+
rows = db.execute("SELECT id, files, what_changed FROM change_log").fetchall()
|
|
97
|
+
count = 0
|
|
98
|
+
for row in rows:
|
|
99
|
+
change_ref = f"change:{row['id']}"
|
|
100
|
+
kg.upsert_node(
|
|
101
|
+
node_type="change",
|
|
102
|
+
node_ref=change_ref,
|
|
103
|
+
label=f"Change #{row['id']}",
|
|
104
|
+
properties={"summary": (row["what_changed"] or "")[:120]},
|
|
105
|
+
)
|
|
106
|
+
for fpath in _parse_files(row["files"] or ""):
|
|
107
|
+
file_ref = f"file:{fpath}"
|
|
108
|
+
kg.upsert_node(
|
|
109
|
+
node_type="file",
|
|
110
|
+
node_ref=file_ref,
|
|
111
|
+
label=os.path.basename(fpath) or fpath,
|
|
112
|
+
)
|
|
113
|
+
kg.upsert_edge(
|
|
114
|
+
source_type="change", source_ref=change_ref,
|
|
115
|
+
relation="touched",
|
|
116
|
+
target_type="file", target_ref=file_ref,
|
|
117
|
+
weight=1.0,
|
|
118
|
+
)
|
|
119
|
+
count += 1
|
|
120
|
+
return count
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def backfill_decisions() -> int:
|
|
124
|
+
"""Read decisions → create decision nodes + decision→area edges."""
|
|
125
|
+
db = get_db()
|
|
126
|
+
rows = db.execute("SELECT id, domain, decision, status FROM decisions").fetchall()
|
|
127
|
+
count = 0
|
|
128
|
+
for row in rows:
|
|
129
|
+
decision_ref = f"decision:{row['id']}"
|
|
130
|
+
kg.upsert_node(
|
|
131
|
+
node_type="decision",
|
|
132
|
+
node_ref=decision_ref,
|
|
133
|
+
label=(row["decision"] or "")[:80] or f"Decision #{row['id']}",
|
|
134
|
+
properties={"domain": row["domain"], "status": row["status"]},
|
|
135
|
+
)
|
|
136
|
+
if row["domain"]:
|
|
137
|
+
kg.upsert_edge(
|
|
138
|
+
source_type="decision", source_ref=decision_ref,
|
|
139
|
+
relation="in_domain",
|
|
140
|
+
target_type="area", target_ref=f"area:{row['domain']}",
|
|
141
|
+
weight=1.0,
|
|
142
|
+
)
|
|
143
|
+
count += 1
|
|
144
|
+
return count
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def backfill_somatic() -> int:
|
|
148
|
+
"""Read somatic_markers from cognitive.db → create file/area nodes with risk."""
|
|
149
|
+
cdb = _cognitive_db()
|
|
150
|
+
rows = cdb.execute(
|
|
151
|
+
"SELECT target, target_type, risk_score, incident_count FROM somatic_markers"
|
|
152
|
+
).fetchall()
|
|
153
|
+
count = 0
|
|
154
|
+
for row in rows:
|
|
155
|
+
target_type = row["target_type"] or "file"
|
|
156
|
+
node_ref = f"{target_type}:{row['target']}"
|
|
157
|
+
kg.upsert_node(
|
|
158
|
+
node_type=target_type,
|
|
159
|
+
node_ref=node_ref,
|
|
160
|
+
label=os.path.basename(row["target"]) or row["target"],
|
|
161
|
+
properties={
|
|
162
|
+
"risk_score": row["risk_score"],
|
|
163
|
+
"incident_count": row["incident_count"],
|
|
164
|
+
},
|
|
165
|
+
)
|
|
166
|
+
count += 1
|
|
167
|
+
cdb.close()
|
|
168
|
+
return count
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def run_full_backfill() -> dict:
|
|
172
|
+
"""Run all backfill functions. Idempotent (upsert-based)."""
|
|
173
|
+
results = {}
|
|
174
|
+
results["entities"] = backfill_entities()
|
|
175
|
+
results["learnings"] = backfill_learnings()
|
|
176
|
+
results["changes"] = backfill_changes()
|
|
177
|
+
results["decisions"] = backfill_decisions()
|
|
178
|
+
results["somatic"] = backfill_somatic()
|
|
179
|
+
results["total"] = sum(results.values())
|
|
180
|
+
return results
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ─── incremental hooks ───────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
def on_learning_add(learning_id: int, category: str, title: str, applies_to: str = "") -> None:
|
|
186
|
+
try:
|
|
187
|
+
learning_ref = f"learning:{learning_id}"
|
|
188
|
+
kg.upsert_node(
|
|
189
|
+
node_type="learning",
|
|
190
|
+
node_ref=learning_ref,
|
|
191
|
+
label=title or f"Learning #{learning_id}",
|
|
192
|
+
properties={"category": category},
|
|
193
|
+
)
|
|
194
|
+
if category:
|
|
195
|
+
kg.upsert_edge(
|
|
196
|
+
source_type="learning", source_ref=learning_ref,
|
|
197
|
+
relation="belongs_to",
|
|
198
|
+
target_type="area", target_ref=f"area:{category}",
|
|
199
|
+
weight=1.0,
|
|
200
|
+
)
|
|
201
|
+
for fpath in _parse_files(applies_to or ""):
|
|
202
|
+
if fpath:
|
|
203
|
+
kg.upsert_edge(
|
|
204
|
+
source_type="learning", source_ref=learning_ref,
|
|
205
|
+
relation="applies_to_file",
|
|
206
|
+
target_type="file", target_ref=f"file:{fpath}",
|
|
207
|
+
weight=0.8,
|
|
208
|
+
)
|
|
209
|
+
except Exception:
|
|
210
|
+
pass
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def on_change_log(change_id: int, files: str, system: str = "") -> None:
|
|
214
|
+
try:
|
|
215
|
+
change_ref = f"change:{change_id}"
|
|
216
|
+
kg.upsert_node(
|
|
217
|
+
node_type="change",
|
|
218
|
+
node_ref=change_ref,
|
|
219
|
+
label=f"Change #{change_id}",
|
|
220
|
+
)
|
|
221
|
+
for fpath in _parse_files(files or ""):
|
|
222
|
+
file_ref = f"file:{fpath}"
|
|
223
|
+
kg.upsert_node(
|
|
224
|
+
node_type="file",
|
|
225
|
+
node_ref=file_ref,
|
|
226
|
+
label=os.path.basename(fpath) or fpath,
|
|
227
|
+
)
|
|
228
|
+
kg.upsert_edge(
|
|
229
|
+
source_type="change", source_ref=change_ref,
|
|
230
|
+
relation="touched",
|
|
231
|
+
target_type="file", target_ref=file_ref,
|
|
232
|
+
weight=1.0,
|
|
233
|
+
)
|
|
234
|
+
if system:
|
|
235
|
+
kg.upsert_edge(
|
|
236
|
+
source_type="change", source_ref=change_ref,
|
|
237
|
+
relation="in_system",
|
|
238
|
+
target_type="area", target_ref=f"area:{system}",
|
|
239
|
+
weight=1.0,
|
|
240
|
+
)
|
|
241
|
+
except Exception:
|
|
242
|
+
pass
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def on_decision_log(decision_id: int, domain: str, decision_text: str) -> None:
|
|
246
|
+
try:
|
|
247
|
+
decision_ref = f"decision:{decision_id}"
|
|
248
|
+
kg.upsert_node(
|
|
249
|
+
node_type="decision",
|
|
250
|
+
node_ref=decision_ref,
|
|
251
|
+
label=(decision_text or "")[:80] or f"Decision #{decision_id}",
|
|
252
|
+
properties={"domain": domain},
|
|
253
|
+
)
|
|
254
|
+
if domain:
|
|
255
|
+
kg.upsert_edge(
|
|
256
|
+
source_type="decision", source_ref=decision_ref,
|
|
257
|
+
relation="in_domain",
|
|
258
|
+
target_type="area", target_ref=f"area:{domain}",
|
|
259
|
+
weight=1.0,
|
|
260
|
+
)
|
|
261
|
+
except Exception:
|
|
262
|
+
pass
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def on_entity_create(entity_id: int, name: str, entity_type: str) -> None:
|
|
266
|
+
try:
|
|
267
|
+
kg.upsert_node(
|
|
268
|
+
node_type="entity",
|
|
269
|
+
node_ref=f"entity:{entity_id}",
|
|
270
|
+
label=name,
|
|
271
|
+
properties={"entity_type": entity_type},
|
|
272
|
+
)
|
|
273
|
+
except Exception:
|
|
274
|
+
pass
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ─── main ────────────────────────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
if __name__ == "__main__":
|
|
280
|
+
print("Running full KG backfill...")
|
|
281
|
+
results = run_full_backfill()
|
|
282
|
+
print("\nBackfill complete:")
|
|
283
|
+
for key, val in results.items():
|
|
284
|
+
if key != "total":
|
|
285
|
+
print(f" {key:12s}: {val:4d} records")
|
|
286
|
+
print(f" {'TOTAL':12s}: {results['total']:4d} nodes/edges processed")
|
|
287
|
+
|
|
288
|
+
# Show KG stats
|
|
289
|
+
s = kg.stats()
|
|
290
|
+
print(f"\nKG state: {s['nodes']} nodes, {s['edges_active']} active edges")
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""NEXO Knowledge Graph — Bi-temporal entity-relationship graph on SQLite."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Optional
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _get_db():
|
|
10
|
+
"""Get cognitive.db connection (KG lives in cognitive.db)."""
|
|
11
|
+
import cognitive
|
|
12
|
+
return cognitive._get_db()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def upsert_node(node_type: str, node_ref: str, label: str, properties: dict = None) -> int:
|
|
16
|
+
db = _get_db()
|
|
17
|
+
props_json = json.dumps(properties or {})
|
|
18
|
+
existing = db.execute(
|
|
19
|
+
"SELECT id FROM kg_nodes WHERE node_type = ? AND node_ref = ?",
|
|
20
|
+
(node_type, node_ref)
|
|
21
|
+
).fetchone()
|
|
22
|
+
if existing:
|
|
23
|
+
db.execute("UPDATE kg_nodes SET label = ?, properties = ? WHERE id = ?",
|
|
24
|
+
(label, props_json, existing["id"]))
|
|
25
|
+
db.commit()
|
|
26
|
+
return existing["id"]
|
|
27
|
+
cursor = db.execute(
|
|
28
|
+
"INSERT INTO kg_nodes (node_type, node_ref, label, properties) VALUES (?, ?, ?, ?)",
|
|
29
|
+
(node_type, node_ref, label, props_json))
|
|
30
|
+
db.commit()
|
|
31
|
+
return cursor.lastrowid
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_node(node_type: str, node_ref: str) -> Optional[dict]:
|
|
35
|
+
db = _get_db()
|
|
36
|
+
row = db.execute("SELECT * FROM kg_nodes WHERE node_type = ? AND node_ref = ?",
|
|
37
|
+
(node_type, node_ref)).fetchone()
|
|
38
|
+
return dict(row) if row else None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_node_by_id(node_id: int) -> Optional[dict]:
|
|
42
|
+
db = _get_db()
|
|
43
|
+
row = db.execute("SELECT * FROM kg_nodes WHERE id = ?", (node_id,)).fetchone()
|
|
44
|
+
return dict(row) if row else None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def upsert_edge(source_type: str, source_ref: str, relation: str,
|
|
48
|
+
target_type: str, target_ref: str,
|
|
49
|
+
weight: float = 1.0, confidence: float = 1.0,
|
|
50
|
+
source_memory_id: str = "", properties: dict = None) -> dict:
|
|
51
|
+
db = _get_db()
|
|
52
|
+
source_node = get_node(source_type, source_ref)
|
|
53
|
+
target_node = get_node(target_type, target_ref)
|
|
54
|
+
if not source_node:
|
|
55
|
+
source_node = {"id": upsert_node(source_type, source_ref, source_ref)}
|
|
56
|
+
if not target_node:
|
|
57
|
+
target_node = {"id": upsert_node(target_type, target_ref, target_ref)}
|
|
58
|
+
source_id = source_node["id"]
|
|
59
|
+
target_id = target_node["id"]
|
|
60
|
+
props_json = json.dumps(properties or {})
|
|
61
|
+
now = datetime.now(timezone.utc).replace(tzinfo=None).strftime("%Y-%m-%dT%H:%M:%S")
|
|
62
|
+
existing = db.execute(
|
|
63
|
+
"SELECT id, weight, confidence, properties FROM kg_edges "
|
|
64
|
+
"WHERE source_id = ? AND target_id = ? AND relation = ? AND valid_until IS NULL",
|
|
65
|
+
(source_id, target_id, relation)).fetchone()
|
|
66
|
+
if existing:
|
|
67
|
+
if (abs(existing["weight"] - weight) < 0.01 and
|
|
68
|
+
abs(existing["confidence"] - confidence) < 0.01 and
|
|
69
|
+
existing["properties"] == props_json):
|
|
70
|
+
return {"action": "NOOP", "edge_id": existing["id"]}
|
|
71
|
+
db.execute("UPDATE kg_edges SET valid_until = ? WHERE id = ?", (now, existing["id"]))
|
|
72
|
+
cursor = db.execute(
|
|
73
|
+
"INSERT INTO kg_edges (source_id, target_id, relation, weight, confidence, "
|
|
74
|
+
"valid_from, source_memory_id, properties) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
75
|
+
(source_id, target_id, relation, weight, confidence, now, source_memory_id, props_json))
|
|
76
|
+
db.commit()
|
|
77
|
+
return {"action": "UPDATE", "edge_id": cursor.lastrowid}
|
|
78
|
+
cursor = db.execute(
|
|
79
|
+
"INSERT INTO kg_edges (source_id, target_id, relation, weight, confidence, "
|
|
80
|
+
"valid_from, source_memory_id, properties) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
81
|
+
(source_id, target_id, relation, weight, confidence, now, source_memory_id, props_json))
|
|
82
|
+
db.commit()
|
|
83
|
+
return {"action": "ADD", "edge_id": cursor.lastrowid}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def delete_edge(source_type: str, source_ref: str, relation: str,
|
|
87
|
+
target_type: str, target_ref: str) -> bool:
|
|
88
|
+
db = _get_db()
|
|
89
|
+
source = get_node(source_type, source_ref)
|
|
90
|
+
target = get_node(target_type, target_ref)
|
|
91
|
+
if not source or not target:
|
|
92
|
+
return False
|
|
93
|
+
now = datetime.now(timezone.utc).replace(tzinfo=None).strftime("%Y-%m-%dT%H:%M:%S")
|
|
94
|
+
cursor = db.execute(
|
|
95
|
+
"UPDATE kg_edges SET valid_until = ? WHERE source_id = ? AND target_id = ? "
|
|
96
|
+
"AND relation = ? AND valid_until IS NULL",
|
|
97
|
+
(now, source["id"], target["id"], relation))
|
|
98
|
+
db.commit()
|
|
99
|
+
return cursor.rowcount > 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_neighbors(node_id: int, relation: str = None, active_only: bool = True) -> list:
|
|
103
|
+
db = _get_db()
|
|
104
|
+
conditions = ["(e.source_id = ? OR e.target_id = ?)"]
|
|
105
|
+
params = [node_id, node_id]
|
|
106
|
+
if active_only:
|
|
107
|
+
conditions.append("e.valid_until IS NULL")
|
|
108
|
+
if relation:
|
|
109
|
+
conditions.append("e.relation = ?")
|
|
110
|
+
params.append(relation)
|
|
111
|
+
where = " AND ".join(conditions)
|
|
112
|
+
rows = db.execute(f"""
|
|
113
|
+
SELECT e.*, n.node_type, n.node_ref, n.label,
|
|
114
|
+
CASE WHEN e.source_id = ? THEN 'outgoing' ELSE 'incoming' END as direction
|
|
115
|
+
FROM kg_edges e
|
|
116
|
+
JOIN kg_nodes n ON n.id = CASE WHEN e.source_id = ? THEN e.target_id ELSE e.source_id END
|
|
117
|
+
WHERE {where}
|
|
118
|
+
ORDER BY e.weight DESC
|
|
119
|
+
""", [node_id, node_id] + params).fetchall()
|
|
120
|
+
return [dict(r) for r in rows]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def traverse(start_id: int, max_depth: int = 3, relation_filter: str = None,
|
|
124
|
+
active_only: bool = True) -> dict:
|
|
125
|
+
visited_nodes = set()
|
|
126
|
+
visited_edges = set()
|
|
127
|
+
result_nodes = []
|
|
128
|
+
result_edges = []
|
|
129
|
+
queue = [(start_id, 0)]
|
|
130
|
+
while queue:
|
|
131
|
+
current_id, depth = queue.pop(0)
|
|
132
|
+
if current_id in visited_nodes or depth > max_depth:
|
|
133
|
+
continue
|
|
134
|
+
visited_nodes.add(current_id)
|
|
135
|
+
node = get_node_by_id(current_id)
|
|
136
|
+
if node:
|
|
137
|
+
node["depth"] = depth
|
|
138
|
+
result_nodes.append(node)
|
|
139
|
+
neighbors = get_neighbors(current_id, relation=relation_filter, active_only=active_only)
|
|
140
|
+
for n in neighbors:
|
|
141
|
+
edge_id = n["id"]
|
|
142
|
+
if edge_id not in visited_edges:
|
|
143
|
+
visited_edges.add(edge_id)
|
|
144
|
+
result_edges.append({
|
|
145
|
+
"id": edge_id, "source_id": n["source_id"], "target_id": n["target_id"],
|
|
146
|
+
"relation": n["relation"], "weight": n["weight"],
|
|
147
|
+
"valid_from": n["valid_from"], "valid_until": n["valid_until"],
|
|
148
|
+
})
|
|
149
|
+
neighbor_id = n["target_id"] if n["source_id"] == current_id else n["source_id"]
|
|
150
|
+
if neighbor_id not in visited_nodes and depth + 1 <= max_depth:
|
|
151
|
+
queue.append((neighbor_id, depth + 1))
|
|
152
|
+
return {"nodes": result_nodes, "edges": result_edges}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def shortest_path(from_id: int, to_id: int, max_depth: int = 6) -> Optional[list]:
|
|
156
|
+
if from_id == to_id:
|
|
157
|
+
return [from_id]
|
|
158
|
+
visited = {from_id}
|
|
159
|
+
queue = [(from_id, [from_id])]
|
|
160
|
+
while queue:
|
|
161
|
+
current, path = queue.pop(0)
|
|
162
|
+
if len(path) > max_depth:
|
|
163
|
+
continue
|
|
164
|
+
neighbors = get_neighbors(current, active_only=True)
|
|
165
|
+
for n in neighbors:
|
|
166
|
+
nid = n["target_id"] if n["source_id"] == current else n["source_id"]
|
|
167
|
+
if nid == to_id:
|
|
168
|
+
return path + [nid]
|
|
169
|
+
if nid not in visited:
|
|
170
|
+
visited.add(nid)
|
|
171
|
+
queue.append((nid, path + [nid]))
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def merge_nodes(keep_id: int, merge_id: int) -> int:
|
|
176
|
+
db = _get_db()
|
|
177
|
+
db.execute("UPDATE kg_edges SET source_id = ? WHERE source_id = ?", (keep_id, merge_id))
|
|
178
|
+
db.execute("UPDATE kg_edges SET target_id = ? WHERE target_id = ?", (keep_id, merge_id))
|
|
179
|
+
# Clean up self-loops created by merge
|
|
180
|
+
db.execute("DELETE FROM kg_edges WHERE source_id = target_id")
|
|
181
|
+
db.execute("DELETE FROM kg_nodes WHERE id = ?", (merge_id,))
|
|
182
|
+
db.commit()
|
|
183
|
+
return keep_id
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def query_at(node_id: int, timestamp: str, relation: str = None) -> list:
|
|
187
|
+
db = _get_db()
|
|
188
|
+
conditions = ["(e.source_id = ? OR e.target_id = ?)",
|
|
189
|
+
"e.valid_from <= ?",
|
|
190
|
+
"(e.valid_until IS NULL OR e.valid_until >= ?)"]
|
|
191
|
+
params = [node_id, node_id, timestamp, timestamp]
|
|
192
|
+
if relation:
|
|
193
|
+
conditions.append("e.relation = ?")
|
|
194
|
+
params.append(relation)
|
|
195
|
+
where = " AND ".join(conditions)
|
|
196
|
+
rows = db.execute(f"""
|
|
197
|
+
SELECT e.*, n.node_type, n.node_ref, n.label
|
|
198
|
+
FROM kg_edges e
|
|
199
|
+
JOIN kg_nodes n ON n.id = CASE WHEN e.source_id = ? THEN e.target_id ELSE e.source_id END
|
|
200
|
+
WHERE {where}
|
|
201
|
+
""", [node_id] + params).fetchall()
|
|
202
|
+
return [dict(r) for r in rows]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def timeline(node_id: int, relation: str = None) -> list:
|
|
206
|
+
db = _get_db()
|
|
207
|
+
conditions = ["(e.source_id = ? OR e.target_id = ?)"]
|
|
208
|
+
params = [node_id, node_id]
|
|
209
|
+
if relation:
|
|
210
|
+
conditions.append("e.relation = ?")
|
|
211
|
+
params.append(relation)
|
|
212
|
+
where = " AND ".join(conditions)
|
|
213
|
+
rows = db.execute(f"""
|
|
214
|
+
SELECT e.*, n.node_type, n.node_ref, n.label
|
|
215
|
+
FROM kg_edges e
|
|
216
|
+
JOIN kg_nodes n ON n.id = CASE WHEN e.source_id = ? THEN e.target_id ELSE e.source_id END
|
|
217
|
+
WHERE {where}
|
|
218
|
+
ORDER BY e.valid_from
|
|
219
|
+
""", [node_id] + params).fetchall()
|
|
220
|
+
return [dict(r) for r in rows]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def stats() -> dict:
|
|
224
|
+
db = _get_db()
|
|
225
|
+
nodes = db.execute("SELECT COUNT(*) FROM kg_nodes").fetchone()[0]
|
|
226
|
+
edges_active = db.execute("SELECT COUNT(*) FROM kg_edges WHERE valid_until IS NULL").fetchone()[0]
|
|
227
|
+
edges_historical = db.execute("SELECT COUNT(*) FROM kg_edges WHERE valid_until IS NOT NULL").fetchone()[0]
|
|
228
|
+
type_counts = {}
|
|
229
|
+
for row in db.execute("SELECT node_type, COUNT(*) as cnt FROM kg_nodes GROUP BY node_type").fetchall():
|
|
230
|
+
type_counts[row["node_type"]] = row["cnt"]
|
|
231
|
+
relation_counts = {}
|
|
232
|
+
for row in db.execute(
|
|
233
|
+
"SELECT relation, COUNT(*) as cnt FROM kg_edges WHERE valid_until IS NULL GROUP BY relation"
|
|
234
|
+
).fetchall():
|
|
235
|
+
relation_counts[row["relation"]] = row["cnt"]
|
|
236
|
+
most_connected = []
|
|
237
|
+
for row in db.execute("""
|
|
238
|
+
SELECT n.id, n.label, n.node_type, COUNT(e.id) as connections
|
|
239
|
+
FROM kg_nodes n
|
|
240
|
+
LEFT JOIN kg_edges e ON (e.source_id = n.id OR e.target_id = n.id) AND e.valid_until IS NULL
|
|
241
|
+
GROUP BY n.id ORDER BY connections DESC LIMIT 10
|
|
242
|
+
""").fetchall():
|
|
243
|
+
most_connected.append(dict(row))
|
|
244
|
+
return {
|
|
245
|
+
"nodes": nodes, "edges_active": edges_active, "edges_historical": edges_historical,
|
|
246
|
+
"node_types": type_counts, "relation_types": relation_counts,
|
|
247
|
+
"most_connected": most_connected,
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def extract_subgraph(center_id: int, depth: int = 2) -> dict:
|
|
252
|
+
graph = traverse(center_id, max_depth=depth)
|
|
253
|
+
d3_nodes = [{"id": n["id"], "label": n["label"], "type": n["node_type"],
|
|
254
|
+
"depth": n.get("depth", 0)} for n in graph["nodes"]]
|
|
255
|
+
d3_edges = [{"source": e["source_id"], "target": e["target_id"],
|
|
256
|
+
"relation": e["relation"], "weight": e["weight"]} for e in graph["edges"]]
|
|
257
|
+
return {"nodes": d3_nodes, "edges": d3_edges}
|