nexo-brain 5.3.19 → 5.3.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.
Files changed (211) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/nexo-brain.js +52 -10
  3. package/package.json +1 -1
  4. package/src/auto_update.py +11 -8
  5. package/src/dashboard/static/favicon 2.svg +32 -0
  6. package/src/dashboard/static/nexo-logo 2.png +0 -0
  7. package/src/dashboard/static/nexo-logo 2.svg +40 -0
  8. package/src/dashboard/static/style 2.css +2458 -0
  9. package/src/dashboard/templates/adaptive 2.html +118 -0
  10. package/src/dashboard/templates/artifacts 2.html +133 -0
  11. package/src/dashboard/templates/backups 2.html +136 -0
  12. package/src/dashboard/templates/base 2.html +417 -0
  13. package/src/dashboard/templates/calendar 2.html +591 -0
  14. package/src/dashboard/templates/chat 2.html +356 -0
  15. package/src/dashboard/templates/claims 2.html +259 -0
  16. package/src/dashboard/templates/cortex 2.html +321 -0
  17. package/src/dashboard/templates/credentials 2.html +128 -0
  18. package/src/dashboard/templates/crons 2.html +370 -0
  19. package/src/dashboard/templates/dashboard 2.html +494 -0
  20. package/src/dashboard/templates/dreams 2.html +252 -0
  21. package/src/dashboard/templates/email 2.html +160 -0
  22. package/src/dashboard/templates/evolution 2.html +189 -0
  23. package/src/dashboard/templates/feed 2.html +249 -0
  24. package/src/dashboard/templates/followup_health 2.html +170 -0
  25. package/src/dashboard/templates/graph 2.html +201 -0
  26. package/src/dashboard/templates/guard 2.html +259 -0
  27. package/src/dashboard/templates/inbox 2.html +251 -0
  28. package/src/dashboard/templates/memory 2.html +420 -0
  29. package/src/dashboard/templates/operations 2.html +608 -0
  30. package/src/dashboard/templates/plugins 2.html +185 -0
  31. package/src/dashboard/templates/protocol 2.html +199 -0
  32. package/src/dashboard/templates/rules 2.html +246 -0
  33. package/src/dashboard/templates/sentiment 2.html +247 -0
  34. package/src/dashboard/templates/sessions 2.html +218 -0
  35. package/src/dashboard/templates/skills 2.html +329 -0
  36. package/src/dashboard/templates/somatic 2.html +73 -0
  37. package/src/dashboard/templates/triggers 2.html +133 -0
  38. package/src/dashboard/templates/trust 2.html +360 -0
  39. package/src/db/__init__ 2.py +259 -0
  40. package/src/db/_core 2.py +437 -0
  41. package/src/db/_credentials 2.py +124 -0
  42. package/src/db/_episodic 2.py +762 -0
  43. package/src/db/_evolution 2.py +54 -0
  44. package/src/db/_fts 2.py +406 -0
  45. package/src/db/_goal_profiles 2.py +376 -0
  46. package/src/db/_hot_context 2.py +660 -0
  47. package/src/db/_outcomes 2.py +800 -0
  48. package/src/db/_personal_scripts 2.py +582 -0
  49. package/src/db/_sessions 2.py +330 -0
  50. package/src/db/_tasks 2.py +91 -0
  51. package/src/db/_watchers 2.py +173 -0
  52. package/src/doctor/formatters 2.py +52 -0
  53. package/src/doctor/models 2.py +69 -0
  54. package/src/doctor/planes 2.py +87 -0
  55. package/src/doctor/providers/__init__ 2.py +1 -0
  56. package/src/doctor/providers/deep 2.py +367 -0
  57. package/src/evolution_cycle 2.py +519 -0
  58. package/src/hooks/auto_capture 2.py +208 -0
  59. package/src/hooks/caffeinate-guard 2.sh +8 -0
  60. package/src/hooks/capture-session 2.sh +21 -0
  61. package/src/hooks/capture-tool-logs 2.sh +158 -0
  62. package/src/hooks/daily-briefing-check 2.sh +33 -0
  63. package/src/hooks/heartbeat-enforcement 2.py +90 -0
  64. package/src/hooks/heartbeat-posttool 2.sh +18 -0
  65. package/src/hooks/inbox-hook 2.sh +76 -0
  66. package/src/hooks/post-compact 2.sh +152 -0
  67. package/src/hooks/pre-compact 2.sh +169 -0
  68. package/src/hooks/protocol-guardrail 2.sh +10 -0
  69. package/src/hooks/protocol-pretool-guardrail 2.sh +9 -0
  70. package/src/hooks/session-stop 2.sh +52 -0
  71. package/src/kg_populate 2.py +292 -0
  72. package/src/maintenance 2.py +53 -0
  73. package/src/memory_backends 2.py +71 -0
  74. package/src/migrate_embeddings 2.py +124 -0
  75. package/src/nexo_sdk 2.py +103 -0
  76. package/src/observability 2.py +199 -0
  77. package/src/plugin_loader 2.py +217 -0
  78. package/src/plugins/__init__ 2.py +0 -0
  79. package/src/plugins/artifact_registry 2.py +450 -0
  80. package/src/plugins/backup 2.py +127 -0
  81. package/src/plugins/claims_tools 2.py +119 -0
  82. package/src/plugins/cognitive_memory 2.py +609 -0
  83. package/src/plugins/core_rules 2.py +252 -0
  84. package/src/plugins/cortex 2.py +1155 -0
  85. package/src/plugins/entities 2.py +67 -0
  86. package/src/plugins/episodic_memory 2.py +560 -0
  87. package/src/plugins/evolution 2.py +167 -0
  88. package/src/plugins/goal_engine 2.py +142 -0
  89. package/src/plugins/guard 2.py +862 -0
  90. package/src/plugins/impact 2.py +29 -0
  91. package/src/plugins/knowledge_graph_tools 2.py +137 -0
  92. package/src/plugins/media_memory_tools 2.py +98 -0
  93. package/src/plugins/memory_export 2.py +196 -0
  94. package/src/plugins/outcomes 2.py +130 -0
  95. package/src/plugins/personal_scripts 2.py +117 -0
  96. package/src/plugins/preferences 2.py +47 -0
  97. package/src/plugins/protocol 2.py +1449 -0
  98. package/src/plugins/simple_api 2.py +106 -0
  99. package/src/plugins/skills 2.py +341 -0
  100. package/src/plugins/state_watchers 2.py +79 -0
  101. package/src/plugins/update 2.py +986 -0
  102. package/src/plugins/user_state_tools 2.py +43 -0
  103. package/src/plugins/workflow 2.py +588 -0
  104. package/src/protocol_settings 2.py +59 -0
  105. package/src/public_contribution 2.py +466 -0
  106. package/src/public_evolution_queue 2.py +241 -0
  107. package/src/requirements 2.txt +14 -0
  108. package/src/retroactive_learnings 2.py +373 -0
  109. package/src/rules/__init__ 2.py +0 -0
  110. package/src/rules/core-rules 2.json +331 -0
  111. package/src/rules/migrate 2.py +207 -0
  112. package/src/runtime_power 2.py +874 -0
  113. package/src/script_registry 2.py +1559 -0
  114. package/src/scripts/check-context 2.py +272 -0
  115. package/src/scripts/deep-sleep/apply_findings 2.py +2327 -0
  116. package/src/scripts/deep-sleep/collect 2.py +928 -0
  117. package/src/scripts/deep-sleep/extract 2.py +330 -0
  118. package/src/scripts/deep-sleep/extract-prompt 2.md +285 -0
  119. package/src/scripts/deep-sleep/synthesize 2.py +312 -0
  120. package/src/scripts/deep-sleep/synthesize-prompt 2.md +336 -0
  121. package/src/scripts/nexo-agent-run 2.py +75 -0
  122. package/src/scripts/nexo-auto-update 2.py +6 -0
  123. package/src/scripts/nexo-backup 2.sh +25 -0
  124. package/src/scripts/nexo-brain-activation 2.sh +140 -0
  125. package/src/scripts/nexo-catchup 2.py +300 -0
  126. package/src/scripts/nexo-cognitive-decay 2.py +257 -0
  127. package/src/scripts/nexo-cortex-cycle 2.py +293 -0
  128. package/src/scripts/nexo-cron-wrapper 2.sh +53 -0
  129. package/src/scripts/nexo-daily-self-audit 2.py +2161 -0
  130. package/src/scripts/nexo-dashboard 2.sh +29 -0
  131. package/src/scripts/nexo-deep-sleep 2.sh +86 -0
  132. package/src/scripts/nexo-evolution-run 2.py +1664 -0
  133. package/src/scripts/nexo-followup-hygiene 2.py +139 -0
  134. package/src/scripts/nexo-hook-record 2.py +42 -0
  135. package/src/scripts/nexo-immune 2.py +936 -0
  136. package/src/scripts/nexo-impact-scorer 2.py +117 -0
  137. package/src/scripts/nexo-inbox-hook 2.sh +74 -0
  138. package/src/scripts/nexo-install 2.py +6 -0
  139. package/src/scripts/nexo-learning-housekeep 2.py +401 -0
  140. package/src/scripts/nexo-learning-validator 2.py +266 -0
  141. package/src/scripts/nexo-migrate 2.py +260 -0
  142. package/src/scripts/nexo-outcome-checker 2.py +127 -0
  143. package/src/scripts/nexo-postmortem-consolidator 2.py +456 -0
  144. package/src/scripts/nexo-pre-commit 2.py +120 -0
  145. package/src/scripts/nexo-prevent-sleep 2.sh +35 -0
  146. package/src/scripts/nexo-proactive-dashboard 2.py +354 -0
  147. package/src/scripts/nexo-reflection 2.py +256 -0
  148. package/src/scripts/nexo-runtime-preflight 2.py +274 -0
  149. package/src/scripts/nexo-sleep 2.py +631 -0
  150. package/src/scripts/nexo-snapshot-restore 2.sh +35 -0
  151. package/src/scripts/nexo-sync-clients 2.py +16 -0
  152. package/src/scripts/nexo-synthesis 2.py +475 -0
  153. package/src/scripts/nexo-tcc-approve 2.sh +79 -0
  154. package/src/scripts/nexo-update 2.sh +306 -0
  155. package/src/scripts/nexo-watchdog 2.sh +1207 -0
  156. package/src/scripts/nexo-watchdog-smoke 2.py +119 -0
  157. package/src/scripts/rehydrate_learnings_from_archive 2.py +245 -0
  158. package/src/server 2.py +1296 -0
  159. package/src/skills/run-nexo-audit-phase/guide 2.md +43 -0
  160. package/src/skills/run-nexo-audit-phase/skill 2.json +59 -0
  161. package/src/skills/run-nexo-core-fix-cycle/guide 2.md +17 -0
  162. package/src/skills/run-nexo-core-fix-cycle/script 2.py +276 -0
  163. package/src/skills/run-nexo-core-fix-cycle/skill 2.json +58 -0
  164. package/src/skills/run-release-final-audit/guide 2.md +16 -0
  165. package/src/skills/run-release-final-audit/script 2.py +259 -0
  166. package/src/skills/run-release-final-audit/skill 2.json +77 -0
  167. package/src/skills/run-runtime-doctor/guide 2.md +12 -0
  168. package/src/skills/run-runtime-doctor/script 2.py +21 -0
  169. package/src/skills/run-runtime-doctor/skill 2.json +25 -0
  170. package/src/skills_runtime 2.py +932 -0
  171. package/src/state_watchers_runtime 2.py +475 -0
  172. package/src/storage_router 2.py +32 -0
  173. package/src/system_catalog 2.py +786 -0
  174. package/src/tools_coordination 2.py +103 -0
  175. package/src/tools_credentials 2.py +68 -0
  176. package/src/tools_drive 2.py +487 -0
  177. package/src/tools_hot_context 2.py +163 -0
  178. package/src/tools_learnings 2.py +612 -0
  179. package/src/tools_menu 2.py +229 -0
  180. package/src/tools_reminders 2.py +88 -0
  181. package/src/tools_reminders_crud 2.py +363 -0
  182. package/src/tools_sessions 2.py +1054 -0
  183. package/src/tools_system_catalog 2.py +19 -0
  184. package/src/tools_task_history 2.py +57 -0
  185. package/src/tools_transcripts 2.py +98 -0
  186. package/src/transcript_utils 2.py +412 -0
  187. package/src/user_context 2.py +46 -0
  188. package/src/user_data_portability 2.py +328 -0
  189. package/src/user_state_model 2.py +170 -0
  190. package/templates/CLAUDE.md 2.template +108 -0
  191. package/templates/CODEX.AGENTS.md 2.template +66 -0
  192. package/templates/launchagents/README 2.md +132 -0
  193. package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +39 -0
  194. package/templates/launchagents/com.nexo.catchup 2.plist +39 -0
  195. package/templates/launchagents/com.nexo.cognitive-decay 2.plist +40 -0
  196. package/templates/launchagents/com.nexo.dashboard 2.plist +43 -0
  197. package/templates/launchagents/com.nexo.deep-sleep 2.plist +43 -0
  198. package/templates/launchagents/com.nexo.evolution 2.plist +44 -0
  199. package/templates/launchagents/com.nexo.followup-hygiene 2.plist +45 -0
  200. package/templates/launchagents/com.nexo.immune 2.plist +41 -0
  201. package/templates/launchagents/com.nexo.postmortem 2.plist +45 -0
  202. package/templates/launchagents/com.nexo.self-audit 2.plist +47 -0
  203. package/templates/launchagents/com.nexo.synthesis 2.plist +45 -0
  204. package/templates/launchagents/com.nexo.watchdog 2.plist +37 -0
  205. package/templates/nexo_helper 2.py +301 -0
  206. package/templates/openclaw 2.json +13 -0
  207. package/templates/plugin-template 2.py +40 -0
  208. package/templates/script-template 2.py +59 -0
  209. package/templates/script-template 2.sh +13 -0
  210. package/templates/skill-script-template 2.py +48 -0
  211. package/templates/skill-template 2.md +33 -0
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env python3
2
+ """NEXO Auto-Capture Hook — Extract facts from conversation context.
3
+
4
+ Inspired by claude-mem's observation handler and transcript processor.
5
+ Uses simple heuristics (no LLM) to extract decisions, corrections,
6
+ and explicit facts from conversation messages.
7
+
8
+ Can be called:
9
+ - Programmatically via process_conversation()
10
+ - From Claude Code hooks via stdin (pipe conversation lines)
11
+ - As CLI: python3 auto_capture.py "message1" "message2" ...
12
+
13
+ Stores extracted facts via cognitive.ingest() with appropriate tags.
14
+ """
15
+
16
+ import re
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ # Add source dir to path for cognitive imports
21
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
22
+ import cognitive
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Pattern definitions (adapted from claude-mem's transcript processor
27
+ # and ShieldCortex's pattern groups approach)
28
+ # ---------------------------------------------------------------------------
29
+
30
+ # Decision patterns — lines indicating a choice was made
31
+ _DECISION_PATTERNS = [
32
+ re.compile(r'\b(?:decided|agreed|will do|changed to|switching to|going with|chose|chosen|opted for)\b', re.IGNORECASE),
33
+ re.compile(r'\b(?:let\'?s go with|the plan is|we\'?ll use|moving forward with)\b', re.IGNORECASE),
34
+ re.compile(r'\b(?:approved|confirmed|locked in|finalized)\b', re.IGNORECASE),
35
+ re.compile(r'\b(?:decidido|acordado|vamos con|cambiamos a|elegimos)\b', re.IGNORECASE), # Spanish
36
+ ]
37
+
38
+ # Correction patterns — lines indicating something was wrong
39
+ _CORRECTION_PATTERNS = [
40
+ re.compile(r'\b(?:don\'?t|stop|wrong|incorrect|that\'?s not right|fix this)\b', re.IGNORECASE),
41
+ re.compile(r'\b(?:should be|actually|not that|the correct|mistake|error)\b', re.IGNORECASE),
42
+ re.compile(r'\b(?:never do that|wrong approach|that broke|revert)\b', re.IGNORECASE),
43
+ re.compile(r'\b(?:no,\s|nope|mal|otra vez|ya te dije|no es|est[aá] mal)\b', re.IGNORECASE), # Spanish
44
+ ]
45
+
46
+ # Explicit fact patterns — user explicitly asks to remember something
47
+ _EXPLICIT_PATTERNS = [
48
+ re.compile(r'\b(?:remember|note that|important:|keep in mind|don\'?t forget)\b', re.IGNORECASE),
49
+ re.compile(r'\b(?:for future reference|take note|key point|rule:)\b', re.IGNORECASE),
50
+ re.compile(r'\b(?:recuerda|importante:|ten en cuenta|no olvides|regla:)\b', re.IGNORECASE), # Spanish
51
+ ]
52
+
53
+ # Minimum line length to consider (skip very short lines)
54
+ _MIN_LINE_LENGTH = 15
55
+
56
+ # Maximum fact content length
57
+ _MAX_FACT_LENGTH = 500
58
+
59
+
60
+ def _classify_line(line: str) -> list[tuple[str, str]]:
61
+ """Classify a single line into fact types.
62
+
63
+ Returns list of (fact_type, content) tuples. A line can match
64
+ multiple categories.
65
+ """
66
+ line = line.strip()
67
+ if len(line) < _MIN_LINE_LENGTH:
68
+ return []
69
+
70
+ facts = []
71
+
72
+ for pattern in _DECISION_PATTERNS:
73
+ if pattern.search(line):
74
+ facts.append(("decision", line))
75
+ break
76
+
77
+ for pattern in _CORRECTION_PATTERNS:
78
+ if pattern.search(line):
79
+ facts.append(("correction", line))
80
+ break
81
+
82
+ for pattern in _EXPLICIT_PATTERNS:
83
+ if pattern.search(line):
84
+ facts.append(("explicit", line))
85
+ break
86
+
87
+ return facts
88
+
89
+
90
+ def process_conversation(messages: list[str]) -> dict:
91
+ """Process conversation messages and extract key facts.
92
+
93
+ Adapted from claude-mem's TranscriptEventProcessor: scans each message
94
+ line for decision, correction, and explicit fact patterns. Stores
95
+ extracted facts via cognitive.ingest() with source_type='auto_capture'.
96
+
97
+ Args:
98
+ messages: List of conversation message strings
99
+
100
+ Returns:
101
+ Dict with facts_extracted, decisions, corrections, stored,
102
+ rejected_by_gate counts and extracted_facts details.
103
+ """
104
+ all_facts = []
105
+ decisions = 0
106
+ corrections = 0
107
+ explicits = 0
108
+
109
+ for msg in messages:
110
+ # Split message into lines and classify each
111
+ for line in msg.split("\n"):
112
+ classified = _classify_line(line)
113
+ for fact_type, content in classified:
114
+ if fact_type == "decision":
115
+ decisions += 1
116
+ elif fact_type == "correction":
117
+ corrections += 1
118
+ elif fact_type == "explicit":
119
+ explicits += 1
120
+ all_facts.append((fact_type, content[:_MAX_FACT_LENGTH]))
121
+
122
+ # Deduplicate by content (same line might appear in multiple messages)
123
+ seen = set()
124
+ unique_facts = []
125
+ for fact_type, content in all_facts:
126
+ content_key = content.lower().strip()
127
+ if content_key not in seen:
128
+ seen.add(content_key)
129
+ unique_facts.append((fact_type, content))
130
+
131
+ # Store via cognitive.ingest()
132
+ stored = 0
133
+ rejected_by_gate = 0
134
+ extracted_details = []
135
+
136
+ for fact_type, content in unique_facts:
137
+ # Build tagged content for better retrieval
138
+ tagged_content = f"[{fact_type.upper()}] {content}"
139
+
140
+ result_id = cognitive.ingest(
141
+ content=tagged_content,
142
+ source_type="auto_capture",
143
+ source_id=f"hook_{fact_type}",
144
+ source_title=f"Auto-captured {fact_type}",
145
+ domain="conversation",
146
+ source="agent_observation",
147
+ skip_quarantine=False, # Route through quarantine for safety
148
+ bypass_gate=False, # Let prediction error gate filter duplicates
149
+ )
150
+
151
+ if result_id == 0:
152
+ rejected_by_gate += 1
153
+ else:
154
+ stored += 1
155
+
156
+ extracted_details.append({
157
+ "type": fact_type,
158
+ "content": content[:100],
159
+ "stored": result_id != 0,
160
+ "memory_id": result_id,
161
+ })
162
+
163
+ return {
164
+ "facts_extracted": len(unique_facts),
165
+ "decisions": decisions,
166
+ "corrections": corrections,
167
+ "explicits": explicits,
168
+ "stored": stored,
169
+ "rejected_by_gate": rejected_by_gate,
170
+ "extracted_facts": extracted_details,
171
+ }
172
+
173
+
174
+ def _read_stdin() -> list[str]:
175
+ """Read conversation lines from stdin (for hook integration)."""
176
+ if sys.stdin.isatty():
177
+ return []
178
+ return [line for line in sys.stdin.read().strip().split("\n") if line.strip()]
179
+
180
+
181
+ def main():
182
+ """CLI entry point — accepts messages as args or from stdin.
183
+
184
+ Usage:
185
+ echo "We decided to use PostgreSQL" | python3 auto_capture.py
186
+ python3 auto_capture.py "Remember: always use WAL mode" "That's wrong, fix it"
187
+ """
188
+ messages = list(sys.argv[1:]) if len(sys.argv) > 1 else _read_stdin()
189
+
190
+ if not messages:
191
+ print("Usage: python3 auto_capture.py 'message1' 'message2' ...")
192
+ print(" or: echo 'messages' | python3 auto_capture.py")
193
+ sys.exit(1)
194
+
195
+ result = process_conversation(messages)
196
+ print(f"Facts extracted: {result['facts_extracted']}")
197
+ print(f" Decisions: {result['decisions']}")
198
+ print(f" Corrections: {result['corrections']}")
199
+ print(f" Explicits: {result['explicits']}")
200
+ print(f"Stored: {result['stored']}, Rejected by gate: {result['rejected_by_gate']}")
201
+
202
+ for fact in result["extracted_facts"]:
203
+ status = "STORED" if fact["stored"] else "REJECTED"
204
+ print(f" [{status}] [{fact['type']}] {fact['content']}")
205
+
206
+
207
+ if __name__ == "__main__":
208
+ main()
@@ -0,0 +1,8 @@
1
+ #!/bin/bash
2
+ # NEXO Caffeinate Guard — keeps the Mac awake so nocturnal processes run on schedule.
3
+ # Runs as a LaunchAgent with KeepAlive=true. If killed, launchd restarts it.
4
+ #
5
+ # Uses the native macOS caffeinate helper. Closed-lid behavior remains
6
+ # best-effort and depends on the host setup.
7
+
8
+ exec /usr/bin/caffeinate -d -i -m -s /bin/bash -lc 'while :; do sleep 3600; done'
@@ -0,0 +1,21 @@
1
+ #!/bin/bash
2
+ # NEXO PostToolUse hook — captures tool usage to session_buffer.jsonl
3
+ # This feeds the Sensory Register (Atkinson-Shiffrin Layer 1)
4
+
5
+ NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
6
+ BUFFER="$NEXO_HOME/brain/session_buffer.jsonl"
7
+
8
+ mkdir -p "$NEXO_HOME/brain"
9
+
10
+ # Capture basic event: timestamp + tool name
11
+ # Read stdin (Claude Code passes JSON via stdin for PostToolUse hooks)
12
+ INPUT=$(cat 2>/dev/null || true)
13
+ TOOL_NAME="${CLAUDE_TOOL_NAME:-unknown}"
14
+ TS=$(date -u +"%Y-%m-%dT%H:%M:%S")
15
+
16
+ # Only log meaningful tool calls (skip reads, globs, greps)
17
+ case "$TOOL_NAME" in
18
+ Read|Glob|Grep|LS|Bash) exit 0 ;;
19
+ esac
20
+
21
+ echo "{\"ts\":\"$TS\",\"tool\":\"$TOOL_NAME\",\"source\":\"hook\"}" >> "$BUFFER"
@@ -0,0 +1,158 @@
1
+ #!/bin/bash
2
+ # NEXO PostToolUse hook — persists tool call outputs to daily JSONL logs
3
+ # Fires automatically after every successful or failed tool use.
4
+ # Logs survive context compactions.
5
+ # Auto-cleanup: deletes logs >= 30 days old.
6
+ # Optimized: skips read-only tools (Read, Grep, Glob, LS, Skill, ToolSearch).
7
+
8
+ # Read full JSON from stdin first
9
+ INPUT=$(cat || true)
10
+ [ -z "$INPUT" ] && exit 0
11
+
12
+ # Extract tool_name early and exit if read-only (avoids overhead on 90%+ of calls)
13
+ TOOL_NAME=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_name',''))" 2>/dev/null || true)
14
+
15
+ case "$TOOL_NAME" in
16
+ Read|Grep|Glob|LS|Skill|ToolSearch) exit 0 ;;
17
+ esac
18
+
19
+ NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
20
+ LOG_DIR="$NEXO_HOME/operations/tool-logs"
21
+ mkdir -p "$LOG_DIR"
22
+
23
+ TODAY=$(date +%Y-%m-%d)
24
+ LOG_FILE="$LOG_DIR/${TODAY}.jsonl"
25
+
26
+ # Build and write record with python3 (faster than jq on macOS when cached)
27
+ # Security: redact output of credential-related tools to avoid plaintext secrets in logs
28
+ echo "$INPUT" | python3 -c "
29
+ import json, sys, re
30
+ from datetime import datetime
31
+ d = json.load(sys.stdin)
32
+ tool_name = d.get('tool_name', 'unknown')
33
+
34
+ tool_input = d.get('tool_input')
35
+ tool_response = d.get('tool_response')
36
+
37
+ # Redact tools that handle credentials/secrets
38
+ SENSITIVE_TOOLS = ('credential', 'secret', 'token', 'password', 'apikey', 'api_key')
39
+ if any(kw in tool_name.lower() for kw in SENSITIVE_TOOLS):
40
+ tool_response = '[REDACTED]'
41
+ # Also redact input values (keep keys for debuggability)
42
+ if isinstance(tool_input, dict):
43
+ tool_input = {k: '[REDACTED]' if k not in ('servicio', 'service', 'name', 'key') else v for k, v in tool_input.items()}
44
+
45
+ record = {
46
+ 'timestamp': datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'),
47
+ 'session_id': d.get('session_id', 'unknown'),
48
+ 'tool_name': tool_name,
49
+ 'hook_event': d.get('hook_event_name', 'unknown'),
50
+ 'tool_use_id': d.get('tool_use_id'),
51
+ 'tool_input': tool_input,
52
+ 'tool_response': tool_response,
53
+ 'error': d.get('error')
54
+ }
55
+ print(json.dumps(record))
56
+ " >> "$LOG_FILE" 2>/dev/null
57
+
58
+ # ── Layer 1: Auto-diary every 10 tool calls (session-scoped) ─────────
59
+ # Extract session_id for per-session counters (prevents cross-terminal contamination)
60
+ SESSION_ID=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('session_id','global'))" 2>/dev/null || echo "global")
61
+ COUNTER_DIR="$NEXO_HOME/operations/counters"
62
+ mkdir -p "$COUNTER_DIR"
63
+ COUNTER_FILE="$COUNTER_DIR/.tool-call-count-${SESSION_ID}"
64
+ NEXO_DB="$NEXO_HOME/data/nexo.db"
65
+
66
+ # Increment counter (atomic: read+write in one step)
67
+ COUNT=1
68
+ if [ -f "$COUNTER_FILE" ]; then
69
+ COUNT=$(( $(cat "$COUNTER_FILE" 2>/dev/null || echo 0) + 1 ))
70
+ fi
71
+ echo "$COUNT" > "$COUNTER_FILE"
72
+
73
+ # Every 10 tool calls, write a mechanical diary draft to SQLite
74
+ if [ $(( COUNT % 10 )) -eq 0 ] && [ -f "$NEXO_DB" ]; then
75
+ python3 -c "
76
+ import json, sqlite3, os, sys
77
+ from datetime import datetime
78
+
79
+ db_path = '$NEXO_DB'
80
+ log_file = '$LOG_FILE'
81
+ count = $COUNT
82
+
83
+ # Read last 10 tool calls from today's log
84
+ entries = []
85
+ if os.path.isfile(log_file):
86
+ with open(log_file, 'r') as f:
87
+ lines = f.readlines()
88
+ for line in lines[-10:]:
89
+ try:
90
+ e = json.loads(line.strip())
91
+ name = e.get('tool_name', '?')
92
+ inp = e.get('tool_input', {})
93
+ # Brief args: first key's value, truncated
94
+ brief = ''
95
+ if isinstance(inp, dict):
96
+ for k, v in list(inp.items())[:1]:
97
+ brief = str(v)[:60]
98
+ entries.append(f'{name}({brief})')
99
+ except Exception:
100
+ pass
101
+
102
+ if not entries:
103
+ sys.exit(0)
104
+
105
+ tools_summary = ', '.join(entries[-10:])
106
+
107
+ # Get session by claude session_id (scoped), fallback to most recent
108
+ session_id = '$SESSION_ID'
109
+ conn = sqlite3.connect(db_path, timeout=2)
110
+ conn.row_factory = sqlite3.Row
111
+
112
+ # Try to find NEXO SID mapped to this claude session_id
113
+ row = None
114
+ if session_id and session_id != 'global':
115
+ row = conn.execute(
116
+ 'SELECT sid, task FROM sessions WHERE external_session_id = ? OR claude_session_id = ? LIMIT 1',
117
+ (session_id, session_id)
118
+ ).fetchone()
119
+
120
+ # Fallback: most recent active session
121
+ if not row:
122
+ row = conn.execute(
123
+ 'SELECT sid, task FROM sessions ORDER BY last_update_epoch DESC LIMIT 1'
124
+ ).fetchone()
125
+
126
+ if not row:
127
+ conn.close()
128
+ sys.exit(0)
129
+
130
+ sid = row['sid']
131
+ task = row['task'] or 'unknown'
132
+
133
+ summary = f'[AUTO-{count}] {len(entries)} tool calls: {tools_summary[:250]}. Task: {task[:100]}'
134
+
135
+ # Write to session_diary_draft (UPSERT)
136
+ conn.execute('''
137
+ INSERT INTO session_diary_draft (sid, summary_draft, tasks_seen, change_ids, decision_ids, last_context_hint, heartbeat_count, updated_at)
138
+ VALUES (?, ?, '[]', '[]', '[]', ?, 0, datetime('now'))
139
+ ON CONFLICT(sid) DO UPDATE SET
140
+ summary_draft = excluded.summary_draft,
141
+ last_context_hint = excluded.last_context_hint,
142
+ updated_at = datetime('now')
143
+ ''', (sid, summary, f'auto-diary at {count} tool calls'))
144
+ conn.commit()
145
+ conn.close()
146
+ " 2>/dev/null &
147
+ # Reset counter after writing
148
+ echo "0" > "$COUNTER_FILE"
149
+ fi
150
+
151
+ # Cleanup: delete logs >= 30 days old (once daily, uses marker file)
152
+ CLEANUP_MARKER="$LOG_DIR/.last-cleanup"
153
+ if [ ! -f "$CLEANUP_MARKER" ] || [ "$(cat "$CLEANUP_MARKER" 2>/dev/null)" != "$TODAY" ]; then
154
+ find "$LOG_DIR" -name "*.jsonl" -mtime +30 -delete 2>/dev/null || true
155
+ echo "$TODAY" > "$CLEANUP_MARKER"
156
+ fi
157
+
158
+ exit 0
@@ -0,0 +1,33 @@
1
+ #!/bin/bash
2
+ # NEXO Daily Briefing — SessionStart hook
3
+ # Checks if a briefing should be sent and creates a flag for NEXO to process.
4
+ # Does NOT send the email directly (needs Claude to research news).
5
+ # Only marks that NEXO should launch the briefing at startup.
6
+ # Frequency: Monday, Wednesday, Friday (3x/week)
7
+
8
+ NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
9
+ BRIEFING_FILE="$NEXO_HOME/operations/.briefing-last-sent"
10
+ FLAG_FILE="$NEXO_HOME/operations/.briefing-pending"
11
+ TODAY=$(date +%Y-%m-%d)
12
+ HOUR=$(date +%H)
13
+ DOW=$(date +%u) # 1=Monday, 7=Sunday
14
+
15
+ # Only after 8:00 AM — before that counts as "previous day"
16
+ if [ "$HOUR" -lt 8 ]; then
17
+ exit 0
18
+ fi
19
+
20
+ # Only Monday (1), Wednesday (3), Friday (5)
21
+ if [ "$DOW" != "1" ] && [ "$DOW" != "3" ] && [ "$DOW" != "5" ]; then
22
+ exit 0
23
+ fi
24
+
25
+ # If already sent today, skip
26
+ LAST_SENT=$(cat "$BRIEFING_FILE" 2>/dev/null)
27
+ if [ "$LAST_SENT" = "$TODAY" ]; then
28
+ exit 0
29
+ fi
30
+
31
+ # Mark briefing as pending for NEXO to launch in background
32
+ echo "$TODAY" > "$FLAG_FILE"
33
+ echo "briefing-pending"
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env python3
2
+ """Heartbeat enforcement for NEXO sessions.
3
+
4
+ Tracks user messages vs heartbeat calls. Emits a warning when more than two
5
+ user messages pass without a heartbeat call.
6
+
7
+ Modes:
8
+ - HEARTBEAT_MODE=user_msg: increment counter on UserPromptSubmit
9
+ - HEARTBEAT_MODE=post_tool: inspect PostToolUse payload, reset on heartbeat,
10
+ warn when other tools keep running without one
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import sys
18
+ import time
19
+ from pathlib import Path
20
+
21
+ STATE_FILE = Path(os.environ.get("NEXO_HOME", Path.home() / ".nexo")) / "operations" / ".heartbeat-state.json"
22
+ THRESHOLD = 2
23
+ HEARTBEAT_TOOL = "nexo_heartbeat"
24
+ SKIP_TOOLS = {"nexo_startup", "nexo_stop", "nexo_smart_startup"}
25
+
26
+
27
+ def _read_state() -> dict:
28
+ try:
29
+ return json.loads(STATE_FILE.read_text())
30
+ except Exception:
31
+ return {"user_msgs": 0, "last_heartbeat_ts": 0.0, "last_user_msg_ts": 0.0}
32
+
33
+
34
+ def _write_state(state: dict) -> None:
35
+ try:
36
+ STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
37
+ STATE_FILE.write_text(json.dumps(state))
38
+ except Exception:
39
+ pass
40
+
41
+
42
+ def handle_user_message() -> int:
43
+ state = _read_state()
44
+ state["user_msgs"] = state.get("user_msgs", 0) + 1
45
+ state["last_user_msg_ts"] = time.time()
46
+ _write_state(state)
47
+ return 0
48
+
49
+
50
+ def handle_post_tool(payload: dict) -> int:
51
+ tool_name = str(payload.get("tool_name", "")).strip()
52
+ short_name = tool_name.rsplit("__", 1)[-1] if "__" in tool_name else tool_name
53
+ state = _read_state()
54
+
55
+ if short_name == HEARTBEAT_TOOL:
56
+ state["user_msgs"] = 0
57
+ state["last_heartbeat_ts"] = time.time()
58
+ _write_state(state)
59
+ return 0
60
+
61
+ if short_name in SKIP_TOOLS:
62
+ return 0
63
+
64
+ user_msgs = state.get("user_msgs", 0)
65
+ if user_msgs > THRESHOLD:
66
+ print(
67
+ f"\nWARNING: HEARTBEAT OVERDUE ({user_msgs} user messages without nexo_heartbeat). "
68
+ "Call nexo_heartbeat(sid=SID, task='...') before continuing."
69
+ )
70
+ return 0
71
+
72
+
73
+ def main() -> int:
74
+ mode = os.environ.get("HEARTBEAT_MODE", "").strip()
75
+ if mode == "user_msg":
76
+ return handle_user_message()
77
+ if mode == "post_tool":
78
+ raw = sys.stdin.read()
79
+ if not raw.strip():
80
+ return 0
81
+ try:
82
+ payload = json.loads(raw)
83
+ except Exception:
84
+ return 0
85
+ return handle_post_tool(payload)
86
+ return 0
87
+
88
+
89
+ if __name__ == "__main__":
90
+ raise SystemExit(main())
@@ -0,0 +1,18 @@
1
+ #!/bin/bash
2
+ # NEXO PostToolUse hook — heartbeat enforcement checker
3
+ set -uo pipefail
4
+
5
+ INPUT=$(cat || true)
6
+ [ -z "$INPUT" ] && exit 0
7
+
8
+ NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
9
+ HELPER=""
10
+ if [ -n "${NEXO_CODE:-}" ] && [ -f "${NEXO_CODE%/}/hooks/heartbeat-enforcement.py" ]; then
11
+ HELPER="${NEXO_CODE%/}/hooks/heartbeat-enforcement.py"
12
+ elif [ -f "$NEXO_HOME/hooks/heartbeat-enforcement.py" ]; then
13
+ HELPER="$NEXO_HOME/hooks/heartbeat-enforcement.py"
14
+ fi
15
+
16
+ [ -z "$HELPER" ] && exit 0
17
+ HEARTBEAT_MODE=post_tool python3 "$HELPER" <<< "$INPUT" 2>/dev/null || true
18
+ exit 0
@@ -0,0 +1,76 @@
1
+ #!/bin/bash
2
+ # NEXO PostToolUse hook — automatic inter-terminal inbox check
3
+ #
4
+ # Zero output when no messages = zero tokens consumed in Claude's context.
5
+ # Reads SQLite directly (no MCP overhead). Write-only: INSERT OR IGNORE for mark-as-read.
6
+ # Debounce: skips if last check was <2 seconds ago.
7
+
8
+ INPUT=$(cat || true)
9
+ [ -z "$INPUT" ] && exit 0
10
+
11
+ # 1. Skip read-only tools (same logic as capture-tool-logs.sh)
12
+ TOOL_NAME=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_name',''))" 2>/dev/null || true)
13
+ case "$TOOL_NAME" in
14
+ Read|Grep|Glob|LS|Skill|ToolSearch|Agent) exit 0 ;;
15
+ esac
16
+
17
+ # 2. Extract Claude Code session_id
18
+ CLAUDE_SID=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('session_id',''))" 2>/dev/null)
19
+ [ -z "$CLAUDE_SID" ] && exit 0
20
+
21
+ # 3. Debounce: skip if last check <2s ago
22
+ DEBOUNCE_FILE="/tmp/nexo-inbox-ts-${CLAUDE_SID}"
23
+ NOW=$(date +%s)
24
+ LAST=$(cat "$DEBOUNCE_FILE" 2>/dev/null || echo 0)
25
+ DIFF=$((NOW - LAST))
26
+ [ "$DIFF" -lt 2 ] && exit 0
27
+ echo "$NOW" > "$DEBOUNCE_FILE"
28
+
29
+ # 4. Find NEXO SID mapped to this Claude session_id
30
+ NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
31
+ DB="$NEXO_HOME/data/nexo.db"
32
+ mkdir -p "$NEXO_HOME/data"
33
+ [ -f "$DB" ] || exit 0
34
+
35
+ NEXO_SID=$(sqlite3 "$DB" "SELECT sid FROM sessions WHERE (external_session_id = '${CLAUDE_SID}' OR claude_session_id = '${CLAUDE_SID}') AND last_update_epoch > (strftime('%s','now') - 900) ORDER BY last_update_epoch DESC LIMIT 1;" 2>/dev/null)
36
+ [ -z "$NEXO_SID" ] && exit 0
37
+
38
+ # 5. Check inbox — messages addressed to this session or broadcast
39
+ MESSAGES=$(sqlite3 -separator '|' "$DB" "
40
+ SELECT m.id, m.from_sid, m.text FROM messages m
41
+ WHERE (m.to_sid = 'all' OR m.to_sid = '${NEXO_SID}')
42
+ AND m.from_sid != '${NEXO_SID}'
43
+ AND m.id NOT IN (SELECT message_id FROM message_reads WHERE sid = '${NEXO_SID}')
44
+ LIMIT 5;
45
+ " 2>/dev/null)
46
+
47
+ # 6. Check pending questions
48
+ QUESTIONS=$(sqlite3 -separator '|' "$DB" "
49
+ SELECT qid, from_sid, question FROM questions
50
+ WHERE to_sid = '${NEXO_SID}' AND answer IS NULL
51
+ LIMIT 3;
52
+ " 2>/dev/null)
53
+
54
+ # 7. If empty -> silent exit (0 tokens consumed)
55
+ [ -z "$MESSAGES" ] && [ -z "$QUESTIONS" ] && exit 0
56
+
57
+ # 8. Format and output (injected into Claude's context)
58
+ echo ""
59
+ echo "INTER-TERMINAL MESSAGE (auto-detected):"
60
+
61
+ if [ -n "$MESSAGES" ]; then
62
+ echo "$MESSAGES" | while IFS='|' read -r mid from text; do
63
+ echo " [$from]: $text"
64
+ # Mark as read (lightweight INSERT, WAL mode, no lock contention)
65
+ sqlite3 "$DB" "INSERT OR IGNORE INTO message_reads (message_id, sid) VALUES ('${mid}', '${NEXO_SID}');" 2>/dev/null
66
+ done
67
+ fi
68
+
69
+ if [ -n "$QUESTIONS" ]; then
70
+ echo " PENDING QUESTIONS from another terminal — respond with nexo_answer:"
71
+ echo "$QUESTIONS" | while IFS='|' read -r qid from question; do
72
+ echo " Q[$qid] from [$from]: $question"
73
+ done
74
+ fi
75
+
76
+ exit 0