nexo-brain 5.3.20 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/src/auto_update.py +11 -8
- package/src/dashboard/static/favicon 2.svg +32 -0
- package/src/dashboard/static/nexo-logo 2.png +0 -0
- package/src/dashboard/static/nexo-logo 2.svg +40 -0
- package/src/dashboard/static/style 2.css +2458 -0
- package/src/dashboard/templates/adaptive 2.html +118 -0
- package/src/dashboard/templates/artifacts 2.html +133 -0
- package/src/dashboard/templates/backups 2.html +136 -0
- package/src/dashboard/templates/base 2.html +417 -0
- package/src/dashboard/templates/calendar 2.html +591 -0
- package/src/dashboard/templates/chat 2.html +356 -0
- package/src/dashboard/templates/claims 2.html +259 -0
- package/src/dashboard/templates/cortex 2.html +321 -0
- package/src/dashboard/templates/credentials 2.html +128 -0
- package/src/dashboard/templates/crons 2.html +370 -0
- package/src/dashboard/templates/dashboard 2.html +494 -0
- package/src/dashboard/templates/dreams 2.html +252 -0
- package/src/dashboard/templates/email 2.html +160 -0
- package/src/dashboard/templates/evolution 2.html +189 -0
- package/src/dashboard/templates/feed 2.html +249 -0
- package/src/dashboard/templates/followup_health 2.html +170 -0
- package/src/dashboard/templates/graph 2.html +201 -0
- package/src/dashboard/templates/guard 2.html +259 -0
- package/src/dashboard/templates/inbox 2.html +251 -0
- package/src/dashboard/templates/memory 2.html +420 -0
- package/src/dashboard/templates/operations 2.html +608 -0
- package/src/dashboard/templates/plugins 2.html +185 -0
- package/src/dashboard/templates/protocol 2.html +199 -0
- package/src/dashboard/templates/rules 2.html +246 -0
- package/src/dashboard/templates/sentiment 2.html +247 -0
- package/src/dashboard/templates/sessions 2.html +218 -0
- package/src/dashboard/templates/skills 2.html +329 -0
- package/src/dashboard/templates/somatic 2.html +73 -0
- package/src/dashboard/templates/triggers 2.html +133 -0
- package/src/dashboard/templates/trust 2.html +360 -0
- package/src/db/__init__ 2.py +259 -0
- package/src/db/_core 2.py +437 -0
- package/src/db/_credentials 2.py +124 -0
- package/src/db/_episodic 2.py +762 -0
- package/src/db/_evolution 2.py +54 -0
- package/src/db/_fts 2.py +406 -0
- package/src/db/_goal_profiles 2.py +376 -0
- package/src/db/_hot_context 2.py +660 -0
- package/src/db/_outcomes 2.py +800 -0
- package/src/db/_personal_scripts 2.py +582 -0
- package/src/db/_sessions 2.py +330 -0
- package/src/db/_tasks 2.py +91 -0
- package/src/db/_watchers 2.py +173 -0
- package/src/doctor/formatters 2.py +52 -0
- package/src/doctor/models 2.py +69 -0
- package/src/doctor/planes 2.py +87 -0
- package/src/doctor/providers/__init__ 2.py +1 -0
- package/src/doctor/providers/deep 2.py +367 -0
- package/src/evolution_cycle 2.py +519 -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-tool-logs 2.sh +158 -0
- package/src/hooks/daily-briefing-check 2.sh +33 -0
- package/src/hooks/heartbeat-enforcement 2.py +90 -0
- package/src/hooks/heartbeat-posttool 2.sh +18 -0
- package/src/hooks/inbox-hook 2.sh +76 -0
- package/src/hooks/post-compact 2.sh +152 -0
- package/src/hooks/pre-compact 2.sh +169 -0
- package/src/hooks/protocol-guardrail 2.sh +10 -0
- package/src/hooks/protocol-pretool-guardrail 2.sh +9 -0
- package/src/hooks/session-stop 2.sh +52 -0
- package/src/kg_populate 2.py +292 -0
- package/src/maintenance 2.py +53 -0
- package/src/memory_backends 2.py +71 -0
- package/src/migrate_embeddings 2.py +124 -0
- package/src/nexo_sdk 2.py +103 -0
- package/src/observability 2.py +199 -0
- package/src/plugin_loader 2.py +217 -0
- package/src/plugins/__init__ 2.py +0 -0
- package/src/plugins/artifact_registry 2.py +450 -0
- package/src/plugins/backup 2.py +127 -0
- package/src/plugins/claims_tools 2.py +119 -0
- package/src/plugins/cognitive_memory 2.py +609 -0
- package/src/plugins/core_rules 2.py +252 -0
- package/src/plugins/cortex 2.py +1155 -0
- package/src/plugins/entities 2.py +67 -0
- package/src/plugins/episodic_memory 2.py +560 -0
- package/src/plugins/evolution 2.py +167 -0
- package/src/plugins/goal_engine 2.py +142 -0
- package/src/plugins/guard 2.py +862 -0
- package/src/plugins/impact 2.py +29 -0
- package/src/plugins/knowledge_graph_tools 2.py +137 -0
- package/src/plugins/media_memory_tools 2.py +98 -0
- package/src/plugins/memory_export 2.py +196 -0
- package/src/plugins/outcomes 2.py +130 -0
- package/src/plugins/personal_scripts 2.py +117 -0
- package/src/plugins/preferences 2.py +47 -0
- package/src/plugins/protocol 2.py +1449 -0
- package/src/plugins/simple_api 2.py +106 -0
- package/src/plugins/skills 2.py +341 -0
- package/src/plugins/state_watchers 2.py +79 -0
- package/src/plugins/update 2.py +986 -0
- package/src/plugins/user_state_tools 2.py +43 -0
- package/src/plugins/workflow 2.py +588 -0
- package/src/protocol_settings 2.py +59 -0
- package/src/public_contribution 2.py +466 -0
- package/src/public_evolution_queue 2.py +241 -0
- package/src/requirements 2.txt +14 -0
- package/src/retroactive_learnings 2.py +373 -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/runtime_power 2.py +874 -0
- package/src/script_registry 2.py +1559 -0
- package/src/scripts/check-context 2.py +272 -0
- package/src/scripts/deep-sleep/apply_findings 2.py +2327 -0
- package/src/scripts/deep-sleep/collect 2.py +928 -0
- package/src/scripts/deep-sleep/extract 2.py +330 -0
- package/src/scripts/deep-sleep/extract-prompt 2.md +285 -0
- package/src/scripts/deep-sleep/synthesize 2.py +312 -0
- package/src/scripts/deep-sleep/synthesize-prompt 2.md +336 -0
- package/src/scripts/nexo-agent-run 2.py +75 -0
- 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 +300 -0
- package/src/scripts/nexo-cognitive-decay 2.py +257 -0
- package/src/scripts/nexo-cortex-cycle 2.py +293 -0
- package/src/scripts/nexo-cron-wrapper 2.sh +53 -0
- package/src/scripts/nexo-daily-self-audit 2.py +2161 -0
- package/src/scripts/nexo-dashboard 2.sh +29 -0
- package/src/scripts/nexo-deep-sleep 2.sh +86 -0
- package/src/scripts/nexo-evolution-run 2.py +1664 -0
- package/src/scripts/nexo-followup-hygiene 2.py +139 -0
- package/src/scripts/nexo-hook-record 2.py +42 -0
- package/src/scripts/nexo-immune 2.py +936 -0
- package/src/scripts/nexo-impact-scorer 2.py +117 -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 +401 -0
- package/src/scripts/nexo-learning-validator 2.py +266 -0
- package/src/scripts/nexo-migrate 2.py +260 -0
- package/src/scripts/nexo-outcome-checker 2.py +127 -0
- package/src/scripts/nexo-postmortem-consolidator 2.py +456 -0
- package/src/scripts/nexo-pre-commit 2.py +120 -0
- package/src/scripts/nexo-prevent-sleep 2.sh +35 -0
- package/src/scripts/nexo-proactive-dashboard 2.py +354 -0
- package/src/scripts/nexo-reflection 2.py +256 -0
- package/src/scripts/nexo-runtime-preflight 2.py +274 -0
- package/src/scripts/nexo-sleep 2.py +631 -0
- package/src/scripts/nexo-snapshot-restore 2.sh +35 -0
- package/src/scripts/nexo-sync-clients 2.py +16 -0
- package/src/scripts/nexo-synthesis 2.py +475 -0
- package/src/scripts/nexo-tcc-approve 2.sh +79 -0
- package/src/scripts/nexo-update 2.sh +306 -0
- package/src/scripts/nexo-watchdog 2.sh +1207 -0
- package/src/scripts/nexo-watchdog-smoke 2.py +119 -0
- package/src/scripts/rehydrate_learnings_from_archive 2.py +245 -0
- package/src/server 2.py +1296 -0
- package/src/skills/run-nexo-audit-phase/guide 2.md +43 -0
- package/src/skills/run-nexo-audit-phase/skill 2.json +59 -0
- package/src/skills/run-nexo-core-fix-cycle/guide 2.md +17 -0
- package/src/skills/run-nexo-core-fix-cycle/script 2.py +276 -0
- package/src/skills/run-nexo-core-fix-cycle/skill 2.json +58 -0
- package/src/skills/run-release-final-audit/guide 2.md +16 -0
- package/src/skills/run-release-final-audit/script 2.py +259 -0
- package/src/skills/run-release-final-audit/skill 2.json +77 -0
- package/src/skills/run-runtime-doctor/guide 2.md +12 -0
- package/src/skills/run-runtime-doctor/script 2.py +21 -0
- package/src/skills/run-runtime-doctor/skill 2.json +25 -0
- package/src/skills_runtime 2.py +932 -0
- package/src/state_watchers_runtime 2.py +475 -0
- package/src/storage_router 2.py +32 -0
- package/src/system_catalog 2.py +786 -0
- package/src/tools_coordination 2.py +103 -0
- package/src/tools_credentials 2.py +68 -0
- package/src/tools_drive 2.py +487 -0
- package/src/tools_hot_context 2.py +163 -0
- package/src/tools_learnings 2.py +612 -0
- package/src/tools_menu 2.py +229 -0
- package/src/tools_reminders 2.py +88 -0
- package/src/tools_reminders_crud 2.py +363 -0
- package/src/tools_sessions 2.py +1054 -0
- package/src/tools_system_catalog 2.py +19 -0
- package/src/tools_task_history 2.py +57 -0
- package/src/tools_transcripts 2.py +98 -0
- package/src/transcript_utils 2.py +412 -0
- package/src/user_context 2.py +46 -0
- package/src/user_data_portability 2.py +328 -0
- package/src/user_state_model 2.py +170 -0
- package/templates/CLAUDE.md 2.template +108 -0
- package/templates/CODEX.AGENTS.md 2.template +66 -0
- package/templates/launchagents/README 2.md +132 -0
- package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +39 -0
- package/templates/launchagents/com.nexo.catchup 2.plist +39 -0
- package/templates/launchagents/com.nexo.cognitive-decay 2.plist +40 -0
- package/templates/launchagents/com.nexo.dashboard 2.plist +43 -0
- package/templates/launchagents/com.nexo.deep-sleep 2.plist +43 -0
- package/templates/launchagents/com.nexo.evolution 2.plist +44 -0
- package/templates/launchagents/com.nexo.followup-hygiene 2.plist +45 -0
- package/templates/launchagents/com.nexo.immune 2.plist +41 -0
- package/templates/launchagents/com.nexo.postmortem 2.plist +45 -0
- package/templates/launchagents/com.nexo.self-audit 2.plist +47 -0
- package/templates/launchagents/com.nexo.synthesis 2.plist +45 -0
- package/templates/launchagents/com.nexo.watchdog 2.plist +37 -0
- package/templates/nexo_helper 2.py +301 -0
- package/templates/openclaw 2.json +13 -0
- package/templates/plugin-template 2.py +40 -0
- package/templates/script-template 2.py +59 -0
- package/templates/script-template 2.sh +13 -0
- package/templates/skill-script-template 2.py +48 -0
- package/templates/skill-template 2.md +33 -0
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
"""NEXO DB — Episodic module."""
|
|
3
|
+
import datetime, time, json
|
|
4
|
+
from db._core import get_db, now_epoch, _multi_word_like
|
|
5
|
+
from db._fts import fts_upsert, fts_search
|
|
6
|
+
|
|
7
|
+
# ── Change Log ───────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
def cleanup_old_changes(retention_days: int = 90) -> int:
|
|
10
|
+
"""Delete change_log entries older than retention_days. Returns count deleted."""
|
|
11
|
+
conn = get_db()
|
|
12
|
+
# Get IDs before deleting so we can clean FTS
|
|
13
|
+
ids = [str(r[0]) for r in conn.execute(
|
|
14
|
+
"SELECT id FROM change_log WHERE created_at < datetime('now', ?)",
|
|
15
|
+
(f"-{retention_days} days",)
|
|
16
|
+
).fetchall()]
|
|
17
|
+
cursor = conn.execute(
|
|
18
|
+
"DELETE FROM change_log WHERE created_at < datetime('now', ?)",
|
|
19
|
+
(f"-{retention_days} days",)
|
|
20
|
+
)
|
|
21
|
+
for cid in ids:
|
|
22
|
+
conn.execute("DELETE FROM unified_search WHERE source = 'change' AND source_id = ?", (cid,))
|
|
23
|
+
conn.commit()
|
|
24
|
+
return cursor.rowcount
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def log_change(session_id: str, files: str, what_changed: str, why: str,
|
|
28
|
+
triggered_by: str = '', affects: str = '', risks: str = '',
|
|
29
|
+
verify: str = '', commit_ref: str = '') -> dict:
|
|
30
|
+
"""Log a code/config change with full context."""
|
|
31
|
+
conn = get_db()
|
|
32
|
+
cleanup_old_changes()
|
|
33
|
+
try:
|
|
34
|
+
cursor = conn.execute(
|
|
35
|
+
"INSERT INTO change_log (session_id, files, what_changed, why, triggered_by, affects, risks, verify, commit_ref) "
|
|
36
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
37
|
+
(session_id, files, what_changed, why, triggered_by, affects, risks, verify, commit_ref)
|
|
38
|
+
)
|
|
39
|
+
conn.commit()
|
|
40
|
+
cid = cursor.lastrowid
|
|
41
|
+
body = f"{what_changed} {why} {triggered_by} {affects} {risks}"
|
|
42
|
+
fts_upsert("change", str(cid), files, body, "change_log", commit=False)
|
|
43
|
+
row = conn.execute("SELECT * FROM change_log WHERE id = ?", (cid,)).fetchone()
|
|
44
|
+
return dict(row)
|
|
45
|
+
except Exception as e:
|
|
46
|
+
return {"error": str(e)}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def search_changes(query: str = '', files: str = '', days: int = 30) -> list[dict]:
|
|
50
|
+
"""Search change log by text and/or file path."""
|
|
51
|
+
conn = get_db()
|
|
52
|
+
days = max(1, int(days))
|
|
53
|
+
conditions = []
|
|
54
|
+
params = []
|
|
55
|
+
if query:
|
|
56
|
+
frag, qparams = _multi_word_like(query, ["what_changed", "why", "affects", "triggered_by"])
|
|
57
|
+
conditions.append(f"({frag})")
|
|
58
|
+
params.extend(qparams)
|
|
59
|
+
if files:
|
|
60
|
+
frag_f, fparams = _multi_word_like(files, ["files"])
|
|
61
|
+
conditions.append(f"({frag_f})")
|
|
62
|
+
params.extend(fparams)
|
|
63
|
+
conditions.append("created_at >= datetime('now', ?)")
|
|
64
|
+
params.append(f"-{days} days")
|
|
65
|
+
where = " AND ".join(conditions)
|
|
66
|
+
rows = conn.execute(
|
|
67
|
+
f"SELECT * FROM change_log WHERE {where} ORDER BY created_at DESC",
|
|
68
|
+
params
|
|
69
|
+
).fetchall()
|
|
70
|
+
return [dict(r) for r in rows]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def auto_resolve_followups(change: dict) -> list[str]:
|
|
74
|
+
"""Cross-reference a change_log entry with open followups. Auto-completes matches.
|
|
75
|
+
|
|
76
|
+
Matching logic:
|
|
77
|
+
1. File overlap: if change touched files mentioned in followup description
|
|
78
|
+
2. Keyword overlap: Jaccard similarity between change text and followup text
|
|
79
|
+
3. ID reference: if followup ID appears in the change's triggered_by/why fields
|
|
80
|
+
|
|
81
|
+
Returns list of followup IDs that were auto-resolved.
|
|
82
|
+
"""
|
|
83
|
+
conn = get_db()
|
|
84
|
+
open_followups = conn.execute(
|
|
85
|
+
"SELECT * FROM followups WHERE status NOT LIKE 'COMPLETED%' "
|
|
86
|
+
"AND status NOT IN ('DELETED','archived','blocked','waiting')"
|
|
87
|
+
).fetchall()
|
|
88
|
+
|
|
89
|
+
if not open_followups:
|
|
90
|
+
return []
|
|
91
|
+
|
|
92
|
+
change_text = " ".join(str(change.get(f, "")) for f in
|
|
93
|
+
["files", "what_changed", "why", "triggered_by", "affects"])
|
|
94
|
+
change_files = set(change.get("files", "").replace(",", " ").split())
|
|
95
|
+
change_tokens = {w.lower() for w in change_text.split() if len(w) > 3}
|
|
96
|
+
|
|
97
|
+
resolved = []
|
|
98
|
+
for f in open_followups:
|
|
99
|
+
fid = f["id"]
|
|
100
|
+
fdesc = f"{fid} {f['description']} {f['verification'] or ''}"
|
|
101
|
+
ftokens = {w.lower() for w in fdesc.split() if len(w) > 3}
|
|
102
|
+
|
|
103
|
+
# Check 1: followup ID explicitly in change trigger/why
|
|
104
|
+
if fid.lower() in change_text.lower():
|
|
105
|
+
resolved.append(fid)
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
# Check 2: file overlap (any changed file mentioned in followup)
|
|
109
|
+
if change_files:
|
|
110
|
+
for cf in change_files:
|
|
111
|
+
basename = cf.rsplit("/", 1)[-1] if "/" in cf else cf
|
|
112
|
+
if basename and len(basename) > 4 and basename.lower() in fdesc.lower():
|
|
113
|
+
resolved.append(fid)
|
|
114
|
+
break
|
|
115
|
+
if fid in resolved:
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
# Check 3: keyword similarity (asymmetric overlap >= 0.35)
|
|
119
|
+
if ftokens and change_tokens:
|
|
120
|
+
intersection = ftokens & change_tokens
|
|
121
|
+
smaller = min(len(ftokens), len(change_tokens))
|
|
122
|
+
score = len(intersection) / smaller if smaller else 0
|
|
123
|
+
if score >= 0.35:
|
|
124
|
+
resolved.append(fid)
|
|
125
|
+
|
|
126
|
+
# Auto-complete matched followups
|
|
127
|
+
from db._reminders import complete_followup
|
|
128
|
+
commit_ref = change.get("commit_ref", "")
|
|
129
|
+
for fid in resolved:
|
|
130
|
+
complete_followup(fid, result=f"Auto-resolved by change #{change.get('id', '?')} (commit {commit_ref[:8] if commit_ref else 'N/A'})")
|
|
131
|
+
|
|
132
|
+
return resolved
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def update_change_commit(id: int, commit_ref: str) -> dict:
|
|
136
|
+
"""Link a change log entry to its git commit after commit.
|
|
137
|
+
|
|
138
|
+
After linking, auto-resolves any open followups that match the change.
|
|
139
|
+
"""
|
|
140
|
+
conn = get_db()
|
|
141
|
+
row = conn.execute("SELECT * FROM change_log WHERE id = ?", (id,)).fetchone()
|
|
142
|
+
if not row:
|
|
143
|
+
return {"error": f"Change {id} not found"}
|
|
144
|
+
conn.execute("UPDATE change_log SET commit_ref = ? WHERE id = ?", (commit_ref, id))
|
|
145
|
+
conn.commit()
|
|
146
|
+
row = conn.execute("SELECT * FROM change_log WHERE id = ?", (id,)).fetchone()
|
|
147
|
+
r = dict(row)
|
|
148
|
+
body = f"{r.get('what_changed','')} {r.get('why','')} {r.get('triggered_by','')} {r.get('affects','')} {r.get('risks','')}"
|
|
149
|
+
fts_upsert("change", str(id), r.get("files",""), body, "change_log", commit=False)
|
|
150
|
+
|
|
151
|
+
# Auto-resolve followups that match this change
|
|
152
|
+
r["_auto_resolved"] = auto_resolve_followups(r)
|
|
153
|
+
return r
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ── Decisions (episodic memory) ──────────────────────────────────
|
|
157
|
+
|
|
158
|
+
def cleanup_old_decisions(retention_days: int = 90) -> int:
|
|
159
|
+
"""Delete decisions entries older than retention_days. Returns count deleted."""
|
|
160
|
+
conn = get_db()
|
|
161
|
+
ids = [str(r[0]) for r in conn.execute(
|
|
162
|
+
"SELECT id FROM decisions WHERE created_at < datetime('now', ?)",
|
|
163
|
+
(f"-{retention_days} days",)
|
|
164
|
+
).fetchall()]
|
|
165
|
+
cursor = conn.execute(
|
|
166
|
+
"DELETE FROM decisions WHERE created_at < datetime('now', ?)",
|
|
167
|
+
(f"-{retention_days} days",)
|
|
168
|
+
)
|
|
169
|
+
for did in ids:
|
|
170
|
+
conn.execute("DELETE FROM unified_search WHERE source = 'decision' AND source_id = ?", (did,))
|
|
171
|
+
conn.commit()
|
|
172
|
+
return cursor.rowcount
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def log_decision(session_id: str, domain: str, decision: str,
|
|
176
|
+
alternatives: str = '', based_on: str = '',
|
|
177
|
+
confidence: str = 'medium', context_ref: str = '',
|
|
178
|
+
status: str = 'pending_review',
|
|
179
|
+
review_due_at: str | None = None) -> dict:
|
|
180
|
+
"""Log a decision with reasoning context."""
|
|
181
|
+
conn = get_db()
|
|
182
|
+
cleanup_old_decisions()
|
|
183
|
+
try:
|
|
184
|
+
cursor = conn.execute(
|
|
185
|
+
"INSERT INTO decisions "
|
|
186
|
+
"(session_id, domain, decision, alternatives, based_on, confidence, context_ref, status, review_due_at) "
|
|
187
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
188
|
+
(
|
|
189
|
+
session_id, domain, decision, alternatives, based_on,
|
|
190
|
+
confidence, context_ref, status, review_due_at,
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
conn.commit()
|
|
194
|
+
did = cursor.lastrowid
|
|
195
|
+
body = f"{decision} {alternatives} {based_on}"
|
|
196
|
+
fts_upsert("decision", str(did), decision[:200], body, domain or '', commit=False)
|
|
197
|
+
row = conn.execute("SELECT * FROM decisions WHERE id = ?", (did,)).fetchone()
|
|
198
|
+
return dict(row)
|
|
199
|
+
except Exception as e:
|
|
200
|
+
return {"error": str(e)}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def update_decision_outcome(id: int, outcome: str) -> dict:
|
|
204
|
+
"""Record the outcome of a past decision."""
|
|
205
|
+
conn = get_db()
|
|
206
|
+
row = conn.execute("SELECT * FROM decisions WHERE id = ?", (id,)).fetchone()
|
|
207
|
+
if not row:
|
|
208
|
+
return {"error": f"Decision {id} not found"}
|
|
209
|
+
conn.execute(
|
|
210
|
+
"UPDATE decisions "
|
|
211
|
+
"SET outcome = ?, outcome_at = datetime('now'), status = 'reviewed', "
|
|
212
|
+
"review_due_at = NULL, last_reviewed_at = datetime('now') "
|
|
213
|
+
"WHERE id = ?",
|
|
214
|
+
(outcome, id)
|
|
215
|
+
)
|
|
216
|
+
conn.commit()
|
|
217
|
+
row = conn.execute("SELECT * FROM decisions WHERE id = ?", (id,)).fetchone()
|
|
218
|
+
r = dict(row)
|
|
219
|
+
body = f"{r.get('decision','')} {r.get('alternatives','')} {r.get('based_on','')} {r.get('outcome','')}"
|
|
220
|
+
fts_upsert("decision", str(id), r.get("decision","")[:200], body, r.get("domain",""), commit=False)
|
|
221
|
+
return r
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def get_memory_review_queue(days: int = 7) -> dict:
|
|
225
|
+
"""Return learnings and decisions whose review date falls within N days."""
|
|
226
|
+
conn = get_db()
|
|
227
|
+
learning_cutoff = now_epoch() + (days * 86400)
|
|
228
|
+
learnings = conn.execute(
|
|
229
|
+
"SELECT * FROM learnings "
|
|
230
|
+
"WHERE review_due_at IS NOT NULL AND review_due_at <= ? "
|
|
231
|
+
"ORDER BY review_due_at ASC, updated_at DESC",
|
|
232
|
+
(learning_cutoff,)
|
|
233
|
+
).fetchall()
|
|
234
|
+
decisions = conn.execute(
|
|
235
|
+
"SELECT * FROM decisions "
|
|
236
|
+
"WHERE review_due_at IS NOT NULL AND review_due_at <= datetime('now', ?) "
|
|
237
|
+
"ORDER BY review_due_at ASC, created_at DESC",
|
|
238
|
+
(f"+{days} days",)
|
|
239
|
+
).fetchall()
|
|
240
|
+
return {
|
|
241
|
+
"learnings": [dict(r) for r in learnings],
|
|
242
|
+
"decisions": [dict(r) for r in decisions],
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def find_decisions_by_context_ref(ref: str) -> list[dict]:
|
|
247
|
+
"""Find decisions linked to a specific context_ref (e.g., followup ID)."""
|
|
248
|
+
conn = get_db()
|
|
249
|
+
rows = conn.execute(
|
|
250
|
+
"SELECT * FROM decisions WHERE context_ref = ? AND (outcome IS NULL OR outcome = '')",
|
|
251
|
+
(ref,)
|
|
252
|
+
).fetchall()
|
|
253
|
+
return [dict(r) for r in rows]
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def search_decisions(query: str = '', domain: str = '', days: int = 30) -> list[dict]:
|
|
257
|
+
"""Search decisions by text and/or domain within a time window."""
|
|
258
|
+
conn = get_db()
|
|
259
|
+
days = max(1, int(days))
|
|
260
|
+
conditions = []
|
|
261
|
+
params = []
|
|
262
|
+
if query:
|
|
263
|
+
frag, qparams = _multi_word_like(query, ["decision", "alternatives", "based_on", "outcome"])
|
|
264
|
+
conditions.append(f"({frag})")
|
|
265
|
+
params.extend(qparams)
|
|
266
|
+
if domain:
|
|
267
|
+
conditions.append("domain = ?")
|
|
268
|
+
params.append(domain)
|
|
269
|
+
conditions.append("created_at >= datetime('now', ?)")
|
|
270
|
+
params.append(f"-{days} days")
|
|
271
|
+
|
|
272
|
+
where = " AND ".join(conditions)
|
|
273
|
+
rows = conn.execute(
|
|
274
|
+
f"SELECT * FROM decisions WHERE {where} ORDER BY created_at DESC",
|
|
275
|
+
params
|
|
276
|
+
).fetchall()
|
|
277
|
+
return [dict(r) for r in rows]
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ── Session Diary ────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
def cleanup_old_diaries(retention_days: int = 180) -> int:
|
|
283
|
+
"""Archive then delete session_diary entries older than retention_days.
|
|
284
|
+
|
|
285
|
+
Diaries are moved to diary_archive (permanent) before being removed from
|
|
286
|
+
the active session_diary table. Nothing is ever truly lost.
|
|
287
|
+
"""
|
|
288
|
+
conn = get_db()
|
|
289
|
+
cutoff = f"-{retention_days} days"
|
|
290
|
+
|
|
291
|
+
# Archive before deleting — permanent subconscious memory
|
|
292
|
+
try:
|
|
293
|
+
conn.execute("""
|
|
294
|
+
INSERT OR IGNORE INTO diary_archive
|
|
295
|
+
(id, session_id, created_at, decisions, discarded, pending,
|
|
296
|
+
context_next, summary, mental_state, domain, user_signals,
|
|
297
|
+
self_critique, source)
|
|
298
|
+
SELECT id, session_id, created_at, decisions, discarded, pending,
|
|
299
|
+
context_next, summary, mental_state, domain, user_signals,
|
|
300
|
+
self_critique, source
|
|
301
|
+
FROM session_diary
|
|
302
|
+
WHERE created_at < datetime('now', ?)
|
|
303
|
+
""", (cutoff,))
|
|
304
|
+
except Exception:
|
|
305
|
+
pass # Table may not exist yet (pre-migration)
|
|
306
|
+
|
|
307
|
+
ids = [str(r[0]) for r in conn.execute(
|
|
308
|
+
"SELECT id FROM session_diary WHERE created_at < datetime('now', ?)",
|
|
309
|
+
(cutoff,)
|
|
310
|
+
).fetchall()]
|
|
311
|
+
cursor = conn.execute(
|
|
312
|
+
"DELETE FROM session_diary WHERE created_at < datetime('now', ?)",
|
|
313
|
+
(cutoff,)
|
|
314
|
+
)
|
|
315
|
+
for did in ids:
|
|
316
|
+
conn.execute("DELETE FROM unified_search WHERE source = 'diary' AND source_id = ?", (did,))
|
|
317
|
+
conn.commit()
|
|
318
|
+
return cursor.rowcount
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def write_session_diary(session_id: str, decisions: str, summary: str,
|
|
322
|
+
discarded: str = '', pending: str = '',
|
|
323
|
+
context_next: str = '', mental_state: str = '',
|
|
324
|
+
domain: str = '', user_signals: str = '',
|
|
325
|
+
self_critique: str = '', source: str = 'claude') -> dict:
|
|
326
|
+
"""Write a session diary entry with mental state and self-critique for continuity."""
|
|
327
|
+
conn = get_db()
|
|
328
|
+
cleanup_old_diaries()
|
|
329
|
+
cursor = conn.execute(
|
|
330
|
+
"INSERT INTO session_diary (session_id, decisions, discarded, pending, context_next, mental_state, summary, domain, user_signals, self_critique, source) "
|
|
331
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
332
|
+
(session_id, decisions, discarded, pending, context_next, mental_state, summary, domain, user_signals, self_critique, source)
|
|
333
|
+
)
|
|
334
|
+
conn.commit()
|
|
335
|
+
did = cursor.lastrowid
|
|
336
|
+
body = f"{summary} {decisions} {pending} {context_next} {mental_state} {self_critique}"
|
|
337
|
+
fts_upsert("diary", str(did), (summary or '')[:200], body, domain or "general", commit=False)
|
|
338
|
+
row = conn.execute("SELECT * FROM session_diary WHERE id = ?", (did,)).fetchone()
|
|
339
|
+
return dict(row)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ── Diary Archive (permanent subconscious) ──────────────────────
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def diary_archive_search(query: str = '', domain: str = '',
|
|
346
|
+
year: int = 0, month: int = 0,
|
|
347
|
+
limit: int = 20) -> list[dict]:
|
|
348
|
+
"""Search the permanent diary archive. Supports text search, domain filter, and date filter.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
query: Text to search in summary, decisions, mental_state, pending
|
|
352
|
+
domain: Filter by domain (e.g. 'project-a', 'project-b')
|
|
353
|
+
year: Filter by year (e.g. 2026)
|
|
354
|
+
month: Filter by month (1-12), requires year
|
|
355
|
+
limit: Max results (default 20)
|
|
356
|
+
"""
|
|
357
|
+
conn = get_db()
|
|
358
|
+
try:
|
|
359
|
+
conn.execute("SELECT 1 FROM diary_archive LIMIT 1")
|
|
360
|
+
except Exception:
|
|
361
|
+
return [] # Table doesn't exist yet
|
|
362
|
+
|
|
363
|
+
conditions = []
|
|
364
|
+
params = []
|
|
365
|
+
|
|
366
|
+
if query:
|
|
367
|
+
words = query.strip().split()
|
|
368
|
+
for word in words:
|
|
369
|
+
conditions.append(
|
|
370
|
+
"(summary LIKE ? OR decisions LIKE ? OR mental_state LIKE ? "
|
|
371
|
+
"OR pending LIKE ? OR self_critique LIKE ?)"
|
|
372
|
+
)
|
|
373
|
+
w = f"%{word}%"
|
|
374
|
+
params.extend([w, w, w, w, w])
|
|
375
|
+
|
|
376
|
+
if domain:
|
|
377
|
+
conditions.append("domain = ?")
|
|
378
|
+
params.append(domain)
|
|
379
|
+
|
|
380
|
+
if year:
|
|
381
|
+
if month:
|
|
382
|
+
date_start = f"{year:04d}-{month:02d}-01"
|
|
383
|
+
if month == 12:
|
|
384
|
+
date_end = f"{year + 1:04d}-01-01"
|
|
385
|
+
else:
|
|
386
|
+
date_end = f"{year:04d}-{month + 1:02d}-01"
|
|
387
|
+
conditions.append("created_at >= ? AND created_at < ?")
|
|
388
|
+
params.extend([date_start, date_end])
|
|
389
|
+
else:
|
|
390
|
+
conditions.append("created_at >= ? AND created_at < ?")
|
|
391
|
+
params.extend([f"{year:04d}-01-01", f"{year + 1:04d}-01-01"])
|
|
392
|
+
|
|
393
|
+
where = " AND ".join(conditions) if conditions else "1=1"
|
|
394
|
+
|
|
395
|
+
rows = conn.execute(f"""
|
|
396
|
+
SELECT id, session_id, created_at, summary, decisions, domain,
|
|
397
|
+
mental_state, pending, self_critique, source
|
|
398
|
+
FROM diary_archive
|
|
399
|
+
WHERE {where}
|
|
400
|
+
ORDER BY created_at DESC
|
|
401
|
+
LIMIT ?
|
|
402
|
+
""", params + [limit]).fetchall()
|
|
403
|
+
return [dict(r) for r in rows]
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def diary_archive_read(diary_id: int) -> dict | None:
|
|
407
|
+
"""Read a single archived diary entry by ID — full content."""
|
|
408
|
+
conn = get_db()
|
|
409
|
+
try:
|
|
410
|
+
row = conn.execute(
|
|
411
|
+
"SELECT * FROM diary_archive WHERE id = ?", (diary_id,)
|
|
412
|
+
).fetchone()
|
|
413
|
+
return dict(row) if row else None
|
|
414
|
+
except Exception:
|
|
415
|
+
return None
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def diary_archive_stats() -> dict:
|
|
419
|
+
"""Get archive statistics: count, date range, domains."""
|
|
420
|
+
conn = get_db()
|
|
421
|
+
try:
|
|
422
|
+
count = conn.execute("SELECT COUNT(*) FROM diary_archive").fetchone()[0]
|
|
423
|
+
if count == 0:
|
|
424
|
+
return {"count": 0, "oldest": None, "newest": None, "domains": []}
|
|
425
|
+
oldest = conn.execute("SELECT MIN(created_at) FROM diary_archive").fetchone()[0]
|
|
426
|
+
newest = conn.execute("SELECT MAX(created_at) FROM diary_archive").fetchone()[0]
|
|
427
|
+
domains = [r[0] for r in conn.execute(
|
|
428
|
+
"SELECT DISTINCT domain FROM diary_archive WHERE domain IS NOT NULL AND domain != '' ORDER BY domain"
|
|
429
|
+
).fetchall()]
|
|
430
|
+
return {"count": count, "oldest": oldest, "newest": newest, "domains": domains}
|
|
431
|
+
except Exception:
|
|
432
|
+
return {"count": 0, "oldest": None, "newest": None, "domains": []}
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def check_session_has_diary(session_id: str) -> bool:
|
|
436
|
+
"""Return True if this session already has a diary entry."""
|
|
437
|
+
conn = get_db()
|
|
438
|
+
row = conn.execute(
|
|
439
|
+
"SELECT id FROM session_diary WHERE session_id = ? LIMIT 1",
|
|
440
|
+
(session_id,)
|
|
441
|
+
).fetchone()
|
|
442
|
+
return row is not None
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
# ── Session Diary Drafts ─────────────────────────────────────────
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def upsert_diary_draft(sid: str, tasks_seen: str, change_ids: str,
|
|
449
|
+
decision_ids: str, last_context_hint: str,
|
|
450
|
+
heartbeat_count: int, summary_draft: str = '') -> dict:
|
|
451
|
+
"""UPSERT diary draft for a session. Called by heartbeat to accumulate context."""
|
|
452
|
+
conn = get_db()
|
|
453
|
+
conn.execute(
|
|
454
|
+
"""INSERT INTO session_diary_draft
|
|
455
|
+
(sid, summary_draft, tasks_seen, change_ids, decision_ids,
|
|
456
|
+
last_context_hint, heartbeat_count, updated_at)
|
|
457
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
458
|
+
ON CONFLICT(sid) DO UPDATE SET
|
|
459
|
+
summary_draft = excluded.summary_draft,
|
|
460
|
+
tasks_seen = excluded.tasks_seen,
|
|
461
|
+
change_ids = excluded.change_ids,
|
|
462
|
+
decision_ids = excluded.decision_ids,
|
|
463
|
+
last_context_hint = excluded.last_context_hint,
|
|
464
|
+
heartbeat_count = excluded.heartbeat_count,
|
|
465
|
+
updated_at = datetime('now')""",
|
|
466
|
+
(sid, summary_draft, tasks_seen, change_ids, decision_ids,
|
|
467
|
+
last_context_hint, heartbeat_count)
|
|
468
|
+
)
|
|
469
|
+
conn.commit()
|
|
470
|
+
return {"sid": sid, "heartbeat_count": heartbeat_count}
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def get_diary_draft(sid: str) -> dict | None:
|
|
474
|
+
"""Get diary draft for a session, or None."""
|
|
475
|
+
conn = get_db()
|
|
476
|
+
row = conn.execute(
|
|
477
|
+
"SELECT * FROM session_diary_draft WHERE sid = ?", (sid,)
|
|
478
|
+
).fetchone()
|
|
479
|
+
return dict(row) if row else None
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def delete_diary_draft(sid: str):
|
|
483
|
+
"""Delete diary draft after real diary is written."""
|
|
484
|
+
conn = get_db()
|
|
485
|
+
conn.execute("DELETE FROM session_diary_draft WHERE sid = ?", (sid,))
|
|
486
|
+
conn.commit()
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
# ── Session Checkpoint operations ──────────────────────────────────
|
|
490
|
+
|
|
491
|
+
def save_checkpoint(sid: str, task: str = '', task_status: str = 'active',
|
|
492
|
+
active_files: str = '[]', current_goal: str = '',
|
|
493
|
+
decisions_summary: str = '', errors_found: str = '',
|
|
494
|
+
reasoning_thread: str = '', next_step: str = '') -> dict:
|
|
495
|
+
"""Save or update a session checkpoint. Called by PreCompact hook."""
|
|
496
|
+
conn = get_db()
|
|
497
|
+
# Get current compaction count
|
|
498
|
+
existing = conn.execute(
|
|
499
|
+
"SELECT compaction_count FROM session_checkpoints WHERE sid = ?", (sid,)
|
|
500
|
+
).fetchone()
|
|
501
|
+
count = (existing["compaction_count"] + 1) if existing else 0
|
|
502
|
+
|
|
503
|
+
conn.execute(
|
|
504
|
+
"""INSERT INTO session_checkpoints
|
|
505
|
+
(sid, task, task_status, active_files, current_goal,
|
|
506
|
+
decisions_summary, errors_found, reasoning_thread, next_step,
|
|
507
|
+
compaction_count, updated_at)
|
|
508
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
509
|
+
ON CONFLICT(sid) DO UPDATE SET
|
|
510
|
+
task = excluded.task,
|
|
511
|
+
task_status = excluded.task_status,
|
|
512
|
+
active_files = excluded.active_files,
|
|
513
|
+
current_goal = excluded.current_goal,
|
|
514
|
+
decisions_summary = excluded.decisions_summary,
|
|
515
|
+
errors_found = excluded.errors_found,
|
|
516
|
+
reasoning_thread = excluded.reasoning_thread,
|
|
517
|
+
next_step = excluded.next_step,
|
|
518
|
+
compaction_count = excluded.compaction_count,
|
|
519
|
+
updated_at = datetime('now')""",
|
|
520
|
+
(sid, task, task_status, active_files, current_goal,
|
|
521
|
+
decisions_summary, errors_found, reasoning_thread, next_step, count)
|
|
522
|
+
)
|
|
523
|
+
conn.commit()
|
|
524
|
+
return {"sid": sid, "compaction_count": count}
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def read_checkpoint(sid: str = '') -> dict | None:
|
|
528
|
+
"""Read the most recent session checkpoint. If no sid, returns the latest."""
|
|
529
|
+
conn = get_db()
|
|
530
|
+
if sid:
|
|
531
|
+
row = conn.execute(
|
|
532
|
+
"SELECT * FROM session_checkpoints WHERE sid = ?", (sid,)
|
|
533
|
+
).fetchone()
|
|
534
|
+
else:
|
|
535
|
+
row = conn.execute(
|
|
536
|
+
"SELECT * FROM session_checkpoints ORDER BY updated_at DESC LIMIT 1"
|
|
537
|
+
).fetchone()
|
|
538
|
+
return dict(row) if row else None
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def increment_compaction_count(sid: str) -> int:
|
|
542
|
+
"""Increment and return the compaction count for a session."""
|
|
543
|
+
conn = get_db()
|
|
544
|
+
conn.execute(
|
|
545
|
+
"""UPDATE session_checkpoints
|
|
546
|
+
SET compaction_count = compaction_count + 1, updated_at = datetime('now')
|
|
547
|
+
WHERE sid = ?""",
|
|
548
|
+
(sid,)
|
|
549
|
+
)
|
|
550
|
+
conn.commit()
|
|
551
|
+
row = conn.execute(
|
|
552
|
+
"SELECT compaction_count FROM session_checkpoints WHERE sid = ?", (sid,)
|
|
553
|
+
).fetchone()
|
|
554
|
+
return row["compaction_count"] if row else 0
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def get_orphan_sessions(ttl_seconds: int = 900) -> list[dict]:
|
|
558
|
+
"""Get sessions that exceeded TTL and have no diary."""
|
|
559
|
+
conn = get_db()
|
|
560
|
+
cutoff = now_epoch() - ttl_seconds
|
|
561
|
+
rows = conn.execute(
|
|
562
|
+
"""SELECT s.sid, s.task, s.started_epoch, s.last_update_epoch
|
|
563
|
+
FROM sessions s
|
|
564
|
+
LEFT JOIN session_diary sd ON sd.session_id = s.sid
|
|
565
|
+
WHERE s.last_update_epoch <= ? AND sd.id IS NULL""",
|
|
566
|
+
(cutoff,)
|
|
567
|
+
).fetchall()
|
|
568
|
+
return [dict(r) for r in rows]
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def read_session_diary(session_id: str = '', last_n: int = 3, last_day: bool = False,
|
|
572
|
+
domain: str = '', include_automated: bool = False) -> list[dict]:
|
|
573
|
+
"""Read session diary entries.
|
|
574
|
+
|
|
575
|
+
- session_id: returns entries for that specific session
|
|
576
|
+
- last_day: returns the recent continuity window (~36h), including the previous evening
|
|
577
|
+
- last_n: returns last N entries (default)
|
|
578
|
+
- domain: filter by project context (nexo, other)
|
|
579
|
+
- include_automated: if False (default), excludes automated sessions (auto-close,
|
|
580
|
+
cron diaries, etc.). Only returns human-interactive sessions.
|
|
581
|
+
Email sessions (user sends email, NEXO responds) ARE included — they're real interactions.
|
|
582
|
+
"""
|
|
583
|
+
conn = get_db()
|
|
584
|
+
domain_clause = " AND domain = ?" if domain else ""
|
|
585
|
+
domain_params = (domain,) if domain else ()
|
|
586
|
+
# By default, filter out automated sessions so startup shows human sessions only.
|
|
587
|
+
# Keeps: interactive sessions + auto-closed sessions that had real user interaction.
|
|
588
|
+
# An auto-close is human if it has heartbeats > 0 (heartbeat only fires on user messages).
|
|
589
|
+
# Excludes: cron jobs, auto-closed crons (0 heartbeats or "Minimal diary").
|
|
590
|
+
if include_automated:
|
|
591
|
+
source_clause = ""
|
|
592
|
+
else:
|
|
593
|
+
source_clause = (
|
|
594
|
+
" AND ("
|
|
595
|
+
" (source = 'claude' AND summary NOT LIKE '[AUTO-%')"
|
|
596
|
+
" OR (source = 'auto-close'"
|
|
597
|
+
" AND mental_state NOT LIKE '%0 heartbeats%'"
|
|
598
|
+
" AND mental_state NOT LIKE '%Minimal diary%')"
|
|
599
|
+
")"
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
if session_id:
|
|
603
|
+
rows = conn.execute(
|
|
604
|
+
f"SELECT * FROM session_diary WHERE session_id = ?{domain_clause} ORDER BY created_at DESC",
|
|
605
|
+
(session_id,) + domain_params
|
|
606
|
+
).fetchall()
|
|
607
|
+
elif last_day:
|
|
608
|
+
rows = conn.execute(
|
|
609
|
+
f"SELECT * FROM session_diary "
|
|
610
|
+
f"WHERE created_at >= datetime('now', '-36 hours'){domain_clause}{source_clause} "
|
|
611
|
+
f"ORDER BY created_at DESC",
|
|
612
|
+
domain_params
|
|
613
|
+
).fetchall()
|
|
614
|
+
else:
|
|
615
|
+
rows = conn.execute(
|
|
616
|
+
f"SELECT * FROM session_diary WHERE 1=1{domain_clause}{source_clause} ORDER BY created_at DESC LIMIT ?",
|
|
617
|
+
domain_params + (last_n,)
|
|
618
|
+
).fetchall()
|
|
619
|
+
return [dict(r) for r in rows]
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _multi_word_like(query: str, columns: list[str]) -> tuple[str, list]:
|
|
623
|
+
"""Build AND-ed LIKE conditions: every word must appear in at least one of the columns.
|
|
624
|
+
|
|
625
|
+
Returns (sql_fragment, params) ready for WHERE clause.
|
|
626
|
+
Example: query="cron learn", columns=["title","content"]
|
|
627
|
+
→ "(title LIKE ? OR content LIKE ?) AND (title LIKE ? OR content LIKE ?)"
|
|
628
|
+
with params ["%cron%","%cron%","%learn%","%learn%"]
|
|
629
|
+
"""
|
|
630
|
+
words = query.strip().split()
|
|
631
|
+
if not words:
|
|
632
|
+
return "1=1", []
|
|
633
|
+
word_conditions = []
|
|
634
|
+
params = []
|
|
635
|
+
for word in words:
|
|
636
|
+
pattern = f"%{word}%"
|
|
637
|
+
col_or = " OR ".join(f"{c} LIKE ?" for c in columns)
|
|
638
|
+
word_conditions.append(f"({col_or})")
|
|
639
|
+
params.extend([pattern] * len(columns))
|
|
640
|
+
return " AND ".join(word_conditions), params
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def recall(query: str, days: int = 30) -> list[dict]:
|
|
644
|
+
"""Cross-search ALL memory using FTS5: learnings, decisions, changes, diary, followups, entities, .md files.
|
|
645
|
+
|
|
646
|
+
Returns up to 20 results ranked by relevance (FTS5 bm25).
|
|
647
|
+
Falls back to LIKE-based search if FTS fails.
|
|
648
|
+
"""
|
|
649
|
+
# Try FTS5 first (fast, ranked), then filter by days
|
|
650
|
+
results = fts_search(query, limit=40) # fetch extra to allow filtering
|
|
651
|
+
if results:
|
|
652
|
+
cutoff_epoch = now_epoch() - (days * 86400)
|
|
653
|
+
filtered = []
|
|
654
|
+
for r in results:
|
|
655
|
+
ua = str(r.get('updated_at', ''))
|
|
656
|
+
if not ua:
|
|
657
|
+
filtered.append(r)
|
|
658
|
+
continue
|
|
659
|
+
# Normalize to epoch for comparison
|
|
660
|
+
try:
|
|
661
|
+
if ua[0].isdigit() and ('.' in ua or len(ua) > 12):
|
|
662
|
+
# Could be epoch float or ISO date
|
|
663
|
+
if '-' in ua[:5]:
|
|
664
|
+
# ISO datetime like "2026-03-13 16:17:40"
|
|
665
|
+
dt = datetime.datetime.fromisoformat(ua.replace(' ', 'T'))
|
|
666
|
+
ts = dt.timestamp()
|
|
667
|
+
else:
|
|
668
|
+
ts = float(ua)
|
|
669
|
+
else:
|
|
670
|
+
ts = float(ua)
|
|
671
|
+
if ts >= cutoff_epoch:
|
|
672
|
+
filtered.append(r)
|
|
673
|
+
except (ValueError, TypeError):
|
|
674
|
+
filtered.append(r) # keep if can't parse
|
|
675
|
+
if filtered:
|
|
676
|
+
return filtered[:20]
|
|
677
|
+
|
|
678
|
+
# Fallback to old LIKE-based search
|
|
679
|
+
days = max(1, int(days))
|
|
680
|
+
conn = get_db()
|
|
681
|
+
cutoff_dt = datetime.datetime.now() - datetime.timedelta(days=days)
|
|
682
|
+
cutoff_str = cutoff_dt.strftime("%Y-%m-%d")
|
|
683
|
+
cutoff_epoch = now_epoch() - (days * 86400)
|
|
684
|
+
|
|
685
|
+
results = []
|
|
686
|
+
|
|
687
|
+
frag, params = _multi_word_like(query, ["files", "what_changed", "why", "triggered_by", "affects", "risks"])
|
|
688
|
+
rows = conn.execute(f"""
|
|
689
|
+
SELECT id, created_at, 'change' AS source,
|
|
690
|
+
files AS title,
|
|
691
|
+
(what_changed || ' | ' || why) AS snippet, 'change_log' AS category, 0 AS rank
|
|
692
|
+
FROM change_log
|
|
693
|
+
WHERE created_at >= ? AND ({frag})
|
|
694
|
+
ORDER BY created_at DESC LIMIT 20
|
|
695
|
+
""", [cutoff_str] + params).fetchall()
|
|
696
|
+
results.extend([dict(r) for r in rows])
|
|
697
|
+
|
|
698
|
+
frag, params = _multi_word_like(query, ["decision", "alternatives", "based_on", "outcome"])
|
|
699
|
+
rows = conn.execute(f"""
|
|
700
|
+
SELECT id, created_at, 'decision' AS source,
|
|
701
|
+
decision AS title,
|
|
702
|
+
(COALESCE(based_on,'') || ' | ' || COALESCE(alternatives,'')) AS snippet, domain AS category, 0 AS rank
|
|
703
|
+
FROM decisions
|
|
704
|
+
WHERE created_at >= ? AND ({frag})
|
|
705
|
+
ORDER BY created_at DESC LIMIT 20
|
|
706
|
+
""", [cutoff_str] + params).fetchall()
|
|
707
|
+
results.extend([dict(r) for r in rows])
|
|
708
|
+
|
|
709
|
+
frag, params = _multi_word_like(query, ["title", "content", "reasoning"])
|
|
710
|
+
rows = conn.execute(f"""
|
|
711
|
+
SELECT id, datetime(created_at, 'unixepoch') AS created_at, 'learning' AS source,
|
|
712
|
+
title,
|
|
713
|
+
(COALESCE(content,'') || ' | ' || COALESCE(reasoning,'')) AS snippet, category, 0 AS rank
|
|
714
|
+
FROM learnings
|
|
715
|
+
WHERE created_at >= ? AND ({frag})
|
|
716
|
+
ORDER BY created_at DESC LIMIT 20
|
|
717
|
+
""", [cutoff_epoch] + params).fetchall()
|
|
718
|
+
results.extend([dict(r) for r in rows])
|
|
719
|
+
|
|
720
|
+
frag, params = _multi_word_like(query, ["id", "description", "verification", "reasoning"])
|
|
721
|
+
rows = conn.execute(f"""
|
|
722
|
+
SELECT id, datetime(created_at, 'unixepoch') AS created_at, 'followup' AS source,
|
|
723
|
+
id AS title,
|
|
724
|
+
(COALESCE(description,'') || ' | ' || COALESCE(verification,'') || ' | ' || COALESCE(reasoning,'')) AS snippet,
|
|
725
|
+
'followup' AS category, 0 AS rank
|
|
726
|
+
FROM followups
|
|
727
|
+
WHERE created_at >= ? AND ({frag})
|
|
728
|
+
ORDER BY created_at DESC LIMIT 20
|
|
729
|
+
""", [cutoff_epoch] + params).fetchall()
|
|
730
|
+
results.extend([dict(r) for r in rows])
|
|
731
|
+
|
|
732
|
+
frag, params = _multi_word_like(query, ["decisions", "discarded", "pending", "context_next", "mental_state", "summary"])
|
|
733
|
+
rows = conn.execute(f"""
|
|
734
|
+
SELECT id, created_at, 'diary' AS source,
|
|
735
|
+
summary AS title,
|
|
736
|
+
(COALESCE(decisions,'') || ' | ' || COALESCE(pending,'') || ' | ' || COALESCE(context_next,'')) AS snippet,
|
|
737
|
+
COALESCE(domain, 'general') AS category, 0 AS rank
|
|
738
|
+
FROM session_diary
|
|
739
|
+
WHERE created_at >= ? AND ({frag})
|
|
740
|
+
ORDER BY created_at DESC LIMIT 20
|
|
741
|
+
""", [cutoff_str] + params).fetchall()
|
|
742
|
+
results.extend([dict(r) for r in rows])
|
|
743
|
+
|
|
744
|
+
# Skills
|
|
745
|
+
try:
|
|
746
|
+
frag, params = _multi_word_like(query, ["name", "description", "tags", "trigger_patterns"])
|
|
747
|
+
rows = conn.execute(f"""
|
|
748
|
+
SELECT id, created_at, 'skill' AS source,
|
|
749
|
+
name AS title,
|
|
750
|
+
(COALESCE(description,'') || ' | ' || COALESCE(tags,'') || ' | ' || COALESCE(trigger_patterns,'')) AS snippet,
|
|
751
|
+
level AS category, 0 AS rank
|
|
752
|
+
FROM skills
|
|
753
|
+
WHERE created_at >= ? AND ({frag})
|
|
754
|
+
ORDER BY trust_score DESC LIMIT 10
|
|
755
|
+
""", [cutoff_str] + params).fetchall()
|
|
756
|
+
results.extend([dict(r) for r in rows])
|
|
757
|
+
except Exception:
|
|
758
|
+
pass # Table may not exist yet during migration
|
|
759
|
+
|
|
760
|
+
results.sort(key=lambda r: r.get('created_at', ''), reverse=True)
|
|
761
|
+
return results[:20]
|
|
762
|
+
|