nexo-brain 3.2.0 → 4.0.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/.claude-plugin/plugin.json +1 -1
- package/README.md +10 -0
- package/package.json +1 -1
- package/src/agent_runner.py +1 -0
- package/src/auto_update.py +53 -0
- package/src/claim_graph.py +128 -15
- package/src/cognitive/_trust.py +2 -2
- package/src/compaction_memory.py +227 -0
- package/src/dashboard/app.py +15 -12
- package/src/doctor/providers/runtime.py +140 -11
- package/src/hook_guardrails.py +105 -9
- package/src/hooks/pre-compact.sh +18 -0
- package/src/media_memory.py +303 -0
- package/src/memory_backends.py +71 -0
- package/src/plugins/claims_tools.py +119 -0
- package/src/plugins/cognitive_memory.py +16 -1
- package/src/plugins/media_memory_tools.py +98 -0
- package/src/plugins/memory_export.py +196 -0
- package/src/plugins/user_state_tools.py +43 -0
- package/src/script_registry.py +31 -14
- package/src/scripts/deep-sleep/collect.py +6 -1
- package/src/server.py +1 -0
- package/src/system_catalog.py +383 -16
- package/src/user_state_model.py +170 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Lightweight multimodal memory reference layer."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import mimetypes
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from db import get_db
|
|
11
|
+
from memory_backends import get_backend
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _safe_json_loads(raw: str | dict | None) -> dict:
|
|
15
|
+
if isinstance(raw, dict):
|
|
16
|
+
return raw
|
|
17
|
+
if not raw:
|
|
18
|
+
return {}
|
|
19
|
+
try:
|
|
20
|
+
value = json.loads(str(raw))
|
|
21
|
+
return value if isinstance(value, dict) else {}
|
|
22
|
+
except Exception:
|
|
23
|
+
return {}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _serialize_metadata(metadata: dict | None) -> str:
|
|
27
|
+
if not metadata:
|
|
28
|
+
return "{}"
|
|
29
|
+
return json.dumps(metadata, ensure_ascii=True, sort_keys=True)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _serialize_tags(tags: str | list[str] | tuple[str, ...] | None) -> str:
|
|
33
|
+
if isinstance(tags, str):
|
|
34
|
+
items = [part.strip() for part in tags.replace("\n", ",").split(",")]
|
|
35
|
+
else:
|
|
36
|
+
items = [str(item).strip() for item in (tags or [])]
|
|
37
|
+
clean = sorted({item for item in items if item})
|
|
38
|
+
return ",".join(clean)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _normalized_path(file_path: str = "") -> str:
|
|
42
|
+
raw = (file_path or "").strip()
|
|
43
|
+
if not raw:
|
|
44
|
+
return ""
|
|
45
|
+
return str(Path(raw).expanduser().resolve())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _detect_media_type(file_path: str = "", url: str = "", mime_type: str = "") -> str:
|
|
49
|
+
mime_guess = mime_type or mimetypes.guess_type(file_path or url or "")[0] or ""
|
|
50
|
+
lowered = mime_guess.lower()
|
|
51
|
+
if lowered.startswith("image/"):
|
|
52
|
+
return "image"
|
|
53
|
+
if lowered.startswith("audio/"):
|
|
54
|
+
return "audio"
|
|
55
|
+
if lowered.startswith("video/"):
|
|
56
|
+
return "video"
|
|
57
|
+
suffix = (Path(file_path or url).suffix or "").lower()
|
|
58
|
+
if suffix in {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}:
|
|
59
|
+
return "image"
|
|
60
|
+
if suffix in {".mp3", ".wav", ".m4a", ".aac", ".ogg"}:
|
|
61
|
+
return "audio"
|
|
62
|
+
if suffix in {".mp4", ".mov", ".avi", ".mkv", ".webm"}:
|
|
63
|
+
return "video"
|
|
64
|
+
if suffix in {".pdf"}:
|
|
65
|
+
return "document"
|
|
66
|
+
return "file"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def init_tables() -> None:
|
|
70
|
+
conn = get_db()
|
|
71
|
+
conn.executescript(
|
|
72
|
+
"""
|
|
73
|
+
CREATE TABLE IF NOT EXISTS media_memories (
|
|
74
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
75
|
+
file_path TEXT DEFAULT '',
|
|
76
|
+
url TEXT DEFAULT '',
|
|
77
|
+
media_type TEXT NOT NULL DEFAULT 'file',
|
|
78
|
+
mime_type TEXT DEFAULT '',
|
|
79
|
+
title TEXT NOT NULL DEFAULT '',
|
|
80
|
+
description TEXT DEFAULT '',
|
|
81
|
+
tags TEXT DEFAULT '',
|
|
82
|
+
domain TEXT DEFAULT '',
|
|
83
|
+
source_type TEXT DEFAULT '',
|
|
84
|
+
source_id TEXT DEFAULT '',
|
|
85
|
+
backend_key TEXT DEFAULT 'sqlite',
|
|
86
|
+
metadata TEXT DEFAULT '{}',
|
|
87
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
88
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
89
|
+
);
|
|
90
|
+
CREATE INDEX IF NOT EXISTS idx_media_memories_type ON media_memories(media_type);
|
|
91
|
+
CREATE INDEX IF NOT EXISTS idx_media_memories_domain ON media_memories(domain);
|
|
92
|
+
CREATE INDEX IF NOT EXISTS idx_media_memories_source ON media_memories(source_type, source_id);
|
|
93
|
+
CREATE INDEX IF NOT EXISTS idx_media_memories_path ON media_memories(file_path);
|
|
94
|
+
CREATE INDEX IF NOT EXISTS idx_media_memories_url ON media_memories(url);
|
|
95
|
+
"""
|
|
96
|
+
)
|
|
97
|
+
conn.commit()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _safe_cognitive_ingest(text: str, source_id: str, title: str, domain: str) -> None:
|
|
101
|
+
try:
|
|
102
|
+
import cognitive
|
|
103
|
+
|
|
104
|
+
cognitive.ingest(text, "media_memory", source_id, title[:120], domain)
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _row_to_dict(row) -> dict:
|
|
110
|
+
result = dict(row)
|
|
111
|
+
result["metadata"] = _safe_json_loads(result.get("metadata"))
|
|
112
|
+
result["tags_list"] = [item for item in str(result.get("tags", "")).split(",") if item]
|
|
113
|
+
return result
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def add_media_memory(
|
|
117
|
+
*,
|
|
118
|
+
file_path: str = "",
|
|
119
|
+
url: str = "",
|
|
120
|
+
title: str = "",
|
|
121
|
+
description: str = "",
|
|
122
|
+
tags: str | list[str] | tuple[str, ...] | None = None,
|
|
123
|
+
domain: str = "",
|
|
124
|
+
source_type: str = "",
|
|
125
|
+
source_id: str = "",
|
|
126
|
+
metadata: str | dict | None = None,
|
|
127
|
+
) -> dict:
|
|
128
|
+
init_tables()
|
|
129
|
+
normalized_path = _normalized_path(file_path)
|
|
130
|
+
clean_url = (url or "").strip()
|
|
131
|
+
if not normalized_path and not clean_url:
|
|
132
|
+
return {"error": "file_path or url is required"}
|
|
133
|
+
|
|
134
|
+
conn = get_db()
|
|
135
|
+
existing = None
|
|
136
|
+
if normalized_path:
|
|
137
|
+
existing = conn.execute(
|
|
138
|
+
"SELECT * FROM media_memories WHERE file_path = ? LIMIT 1",
|
|
139
|
+
(normalized_path,),
|
|
140
|
+
).fetchone()
|
|
141
|
+
elif clean_url:
|
|
142
|
+
existing = conn.execute(
|
|
143
|
+
"SELECT * FROM media_memories WHERE url = ? LIMIT 1",
|
|
144
|
+
(clean_url,),
|
|
145
|
+
).fetchone()
|
|
146
|
+
|
|
147
|
+
metadata_dict = _safe_json_loads(metadata)
|
|
148
|
+
if normalized_path and os.path.exists(normalized_path):
|
|
149
|
+
stat = os.stat(normalized_path)
|
|
150
|
+
metadata_dict.setdefault("size_bytes", int(stat.st_size))
|
|
151
|
+
metadata_dict.setdefault("mtime_epoch", float(stat.st_mtime))
|
|
152
|
+
|
|
153
|
+
resolved_title = (title or Path(normalized_path or clean_url).name or "media-memory").strip()
|
|
154
|
+
resolved_tags = _serialize_tags(tags)
|
|
155
|
+
media_type = _detect_media_type(normalized_path, clean_url, metadata_dict.get("mime_type", ""))
|
|
156
|
+
mime_type = str(metadata_dict.get("mime_type", mimetypes.guess_type(normalized_path or clean_url)[0] or ""))
|
|
157
|
+
|
|
158
|
+
if existing:
|
|
159
|
+
merged_description = description.strip() or existing["description"] or ""
|
|
160
|
+
merged_tags = _serialize_tags(",".join(filter(None, [existing["tags"], resolved_tags])))
|
|
161
|
+
merged_metadata = _safe_json_loads(existing["metadata"])
|
|
162
|
+
merged_metadata.update(metadata_dict)
|
|
163
|
+
conn.execute(
|
|
164
|
+
"""
|
|
165
|
+
UPDATE media_memories
|
|
166
|
+
SET title = ?,
|
|
167
|
+
description = ?,
|
|
168
|
+
tags = ?,
|
|
169
|
+
domain = ?,
|
|
170
|
+
source_type = ?,
|
|
171
|
+
source_id = ?,
|
|
172
|
+
media_type = ?,
|
|
173
|
+
mime_type = ?,
|
|
174
|
+
metadata = ?,
|
|
175
|
+
updated_at = datetime('now')
|
|
176
|
+
WHERE id = ?
|
|
177
|
+
""",
|
|
178
|
+
(
|
|
179
|
+
resolved_title or existing["title"],
|
|
180
|
+
merged_description,
|
|
181
|
+
merged_tags,
|
|
182
|
+
domain.strip() or existing["domain"],
|
|
183
|
+
source_type.strip() or existing["source_type"],
|
|
184
|
+
source_id.strip() or existing["source_id"],
|
|
185
|
+
media_type or existing["media_type"],
|
|
186
|
+
mime_type or existing["mime_type"],
|
|
187
|
+
_serialize_metadata(merged_metadata),
|
|
188
|
+
existing["id"],
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
conn.commit()
|
|
192
|
+
row = conn.execute("SELECT * FROM media_memories WHERE id = ?", (existing["id"],)).fetchone()
|
|
193
|
+
return _row_to_dict(row)
|
|
194
|
+
|
|
195
|
+
backend = get_backend()
|
|
196
|
+
cursor = conn.execute(
|
|
197
|
+
"""
|
|
198
|
+
INSERT INTO media_memories (
|
|
199
|
+
file_path, url, media_type, mime_type, title, description, tags,
|
|
200
|
+
domain, source_type, source_id, backend_key, metadata
|
|
201
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
202
|
+
""",
|
|
203
|
+
(
|
|
204
|
+
normalized_path,
|
|
205
|
+
clean_url,
|
|
206
|
+
media_type,
|
|
207
|
+
mime_type,
|
|
208
|
+
resolved_title,
|
|
209
|
+
description.strip(),
|
|
210
|
+
resolved_tags,
|
|
211
|
+
domain.strip(),
|
|
212
|
+
source_type.strip(),
|
|
213
|
+
source_id.strip(),
|
|
214
|
+
backend.key,
|
|
215
|
+
_serialize_metadata(metadata_dict),
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
conn.commit()
|
|
219
|
+
media_id = int(cursor.lastrowid)
|
|
220
|
+
_safe_cognitive_ingest(
|
|
221
|
+
(
|
|
222
|
+
f"Media memory [{media_type}] {resolved_title}. "
|
|
223
|
+
f"Description: {description.strip() or 'n/a'}. "
|
|
224
|
+
f"Tags: {resolved_tags or 'n/a'}."
|
|
225
|
+
),
|
|
226
|
+
f"MM{media_id}",
|
|
227
|
+
resolved_title,
|
|
228
|
+
domain.strip(),
|
|
229
|
+
)
|
|
230
|
+
row = conn.execute("SELECT * FROM media_memories WHERE id = ?", (media_id,)).fetchone()
|
|
231
|
+
return _row_to_dict(row)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def get_media_memory(media_id: int) -> dict | None:
|
|
235
|
+
init_tables()
|
|
236
|
+
row = get_db().execute("SELECT * FROM media_memories WHERE id = ?", (media_id,)).fetchone()
|
|
237
|
+
return _row_to_dict(row) if row else None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def search_media_memories(
|
|
241
|
+
*,
|
|
242
|
+
query: str = "",
|
|
243
|
+
media_type: str = "",
|
|
244
|
+
domain: str = "",
|
|
245
|
+
tag: str = "",
|
|
246
|
+
limit: int = 20,
|
|
247
|
+
) -> list[dict]:
|
|
248
|
+
init_tables()
|
|
249
|
+
conditions = ["1=1"]
|
|
250
|
+
params: list = []
|
|
251
|
+
if media_type.strip():
|
|
252
|
+
conditions.append("media_type = ?")
|
|
253
|
+
params.append(media_type.strip().lower())
|
|
254
|
+
if domain.strip():
|
|
255
|
+
conditions.append("domain = ?")
|
|
256
|
+
params.append(domain.strip())
|
|
257
|
+
if tag.strip():
|
|
258
|
+
conditions.append("LOWER(tags) LIKE ?")
|
|
259
|
+
params.append(f"%{tag.strip().lower()}%")
|
|
260
|
+
if query.strip():
|
|
261
|
+
conditions.append(
|
|
262
|
+
"(LOWER(title) LIKE ? OR LOWER(description) LIKE ? OR LOWER(tags) LIKE ? OR LOWER(file_path) LIKE ? OR LOWER(url) LIKE ?)"
|
|
263
|
+
)
|
|
264
|
+
q = f"%{query.strip().lower()}%"
|
|
265
|
+
params.extend([q, q, q, q, q])
|
|
266
|
+
rows = get_db().execute(
|
|
267
|
+
f"""
|
|
268
|
+
SELECT * FROM media_memories
|
|
269
|
+
WHERE {' AND '.join(conditions)}
|
|
270
|
+
ORDER BY updated_at DESC, created_at DESC
|
|
271
|
+
LIMIT ?
|
|
272
|
+
""",
|
|
273
|
+
params + [max(1, int(limit or 20))],
|
|
274
|
+
).fetchall()
|
|
275
|
+
return [_row_to_dict(row) for row in rows]
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def list_media_memories(limit: int = 20) -> list[dict]:
|
|
279
|
+
return search_media_memories(limit=limit)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def media_memory_stats() -> dict:
|
|
283
|
+
init_tables()
|
|
284
|
+
conn = get_db()
|
|
285
|
+
total = int(conn.execute("SELECT COUNT(*) FROM media_memories").fetchone()[0])
|
|
286
|
+
by_type = {
|
|
287
|
+
row["media_type"]: row["cnt"]
|
|
288
|
+
for row in conn.execute(
|
|
289
|
+
"SELECT media_type, COUNT(*) AS cnt FROM media_memories GROUP BY media_type"
|
|
290
|
+
).fetchall()
|
|
291
|
+
}
|
|
292
|
+
by_domain = {
|
|
293
|
+
row["domain"]: row["cnt"]
|
|
294
|
+
for row in conn.execute(
|
|
295
|
+
"SELECT domain, COUNT(*) AS cnt FROM media_memories WHERE domain != '' GROUP BY domain ORDER BY cnt DESC LIMIT 10"
|
|
296
|
+
).fetchall()
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
"total": total,
|
|
300
|
+
"by_type": by_type,
|
|
301
|
+
"by_domain": by_domain,
|
|
302
|
+
"backend": get_backend().key,
|
|
303
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Explicit backend registry for memory expansion layers.
|
|
4
|
+
|
|
5
|
+
NEXO's historical memory system is still heavily SQLite-shaped, but newer layers
|
|
6
|
+
should not keep backend assumptions implicit forever. This module introduces a
|
|
7
|
+
small registry/contract that expansion surfaces can use today while SQLite
|
|
8
|
+
remains the default backend.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class MemoryBackendInfo:
|
|
17
|
+
key: str
|
|
18
|
+
label: str
|
|
19
|
+
description: str
|
|
20
|
+
supports: tuple[str, ...]
|
|
21
|
+
maturity: str = "stable"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_REGISTRY: dict[str, MemoryBackendInfo] = {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def register_backend(info: MemoryBackendInfo) -> None:
|
|
28
|
+
_REGISTRY[info.key] = info
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def active_backend_key() -> str:
|
|
32
|
+
return (os.environ.get("NEXO_MEMORY_BACKEND", "sqlite") or "sqlite").strip().lower()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_backend(key: str = "") -> MemoryBackendInfo:
|
|
36
|
+
selected = (key or active_backend_key()).strip().lower()
|
|
37
|
+
return _REGISTRY.get(selected, _REGISTRY["sqlite"])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def list_backends() -> list[dict]:
|
|
41
|
+
active = active_backend_key()
|
|
42
|
+
results = []
|
|
43
|
+
for key in sorted(_REGISTRY):
|
|
44
|
+
info = _REGISTRY[key]
|
|
45
|
+
item = {
|
|
46
|
+
"key": info.key,
|
|
47
|
+
"label": info.label,
|
|
48
|
+
"description": info.description,
|
|
49
|
+
"supports": list(info.supports),
|
|
50
|
+
"maturity": info.maturity,
|
|
51
|
+
"active": info.key == active,
|
|
52
|
+
}
|
|
53
|
+
results.append(item)
|
|
54
|
+
return results
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
register_backend(
|
|
58
|
+
MemoryBackendInfo(
|
|
59
|
+
key="sqlite",
|
|
60
|
+
label="SQLite + FTS5",
|
|
61
|
+
description="Local-first default backend used by NEXO runtime surfaces.",
|
|
62
|
+
supports=(
|
|
63
|
+
"cognitive_core",
|
|
64
|
+
"claims",
|
|
65
|
+
"media_memory",
|
|
66
|
+
"user_state",
|
|
67
|
+
"memory_export",
|
|
68
|
+
"auto_flush",
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Claims/wiki plugin — public surface over NEXO claim graph."""
|
|
2
|
+
|
|
3
|
+
import claim_graph
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def handle_claim_add(
|
|
7
|
+
text: str,
|
|
8
|
+
domain: str = "",
|
|
9
|
+
evidence: str = "",
|
|
10
|
+
confidence: float = 1.0,
|
|
11
|
+
source_type: str = "",
|
|
12
|
+
source_id: str = "",
|
|
13
|
+
freshness_days: int = 30,
|
|
14
|
+
) -> str:
|
|
15
|
+
result = claim_graph.add_claim(
|
|
16
|
+
text=text,
|
|
17
|
+
domain=domain,
|
|
18
|
+
evidence=evidence,
|
|
19
|
+
confidence=confidence,
|
|
20
|
+
source_type=source_type,
|
|
21
|
+
source_id=source_id,
|
|
22
|
+
freshness_days=freshness_days,
|
|
23
|
+
)
|
|
24
|
+
if result.get("error"):
|
|
25
|
+
return f"ERROR: {result['error']}"
|
|
26
|
+
return f"Claim #{result['id']} {result['action']} (confidence={result['confidence']})"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def handle_claim_search(query: str = "", domain: str = "", status: str = "", limit: int = 20) -> str:
|
|
30
|
+
items = claim_graph.search_claims(query=query, domain=domain, status=status, limit=limit)
|
|
31
|
+
if not items:
|
|
32
|
+
return "No claims found."
|
|
33
|
+
lines = [f"CLAIMS — {len(items)} result(s):", ""]
|
|
34
|
+
for item in items:
|
|
35
|
+
lines.append(
|
|
36
|
+
f" #{item['id']} [{item.get('verification_status','unverified')}] "
|
|
37
|
+
f"freshness={item.get('freshness_state','?')}({item.get('freshness_score',0)})"
|
|
38
|
+
)
|
|
39
|
+
lines.append(f" {item['text'][:220]}")
|
|
40
|
+
if item.get("evidence"):
|
|
41
|
+
lines.append(f" evidence: {str(item['evidence'])[:180]}")
|
|
42
|
+
if item.get("domain"):
|
|
43
|
+
lines.append(f" domain: {item['domain']}")
|
|
44
|
+
return "\n".join(lines)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def handle_claim_get(claim_id: int) -> str:
|
|
48
|
+
item = claim_graph.get_claim(claim_id)
|
|
49
|
+
if not item:
|
|
50
|
+
return f"Claim #{claim_id} not found."
|
|
51
|
+
lines = [
|
|
52
|
+
f"CLAIM #{item['id']}",
|
|
53
|
+
f" status: {item.get('verification_status', 'unverified')}",
|
|
54
|
+
f" confidence: {item.get('confidence', 0)}",
|
|
55
|
+
f" freshness: {item.get('freshness_state', '?')} ({item.get('freshness_score', 0)})",
|
|
56
|
+
f" age_days: {item.get('age_days', 0)}",
|
|
57
|
+
f" domain: {item.get('domain', '') or 'n/a'}",
|
|
58
|
+
f" source: {item.get('source_type', '')}:{item.get('source_id', '')}",
|
|
59
|
+
f" text: {item.get('text', '')}",
|
|
60
|
+
]
|
|
61
|
+
if item.get("evidence"):
|
|
62
|
+
lines.append(f" evidence: {item['evidence']}")
|
|
63
|
+
if item.get("links_out"):
|
|
64
|
+
lines.append(f" links_out: {len(item['links_out'])}")
|
|
65
|
+
if item.get("links_in"):
|
|
66
|
+
lines.append(f" links_in: {len(item['links_in'])}")
|
|
67
|
+
return "\n".join(lines)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def handle_claim_link(source_claim_id: int, target_claim_id: int, relation: str, confidence: float = 1.0) -> str:
|
|
71
|
+
result = claim_graph.link_claims(source_claim_id, target_claim_id, relation, confidence=confidence)
|
|
72
|
+
if result.get("error"):
|
|
73
|
+
return f"ERROR: {result['error']}"
|
|
74
|
+
return f"Linked claim #{source_claim_id} -> #{target_claim_id} [{relation}]"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def handle_claim_verify(claim_id: int, status: str = "confirmed") -> str:
|
|
78
|
+
result = claim_graph.verify_claim(claim_id, status=status)
|
|
79
|
+
if result.get("error"):
|
|
80
|
+
return f"ERROR: {result['error']}"
|
|
81
|
+
return (
|
|
82
|
+
f"Claim #{result['id']} now {result['verification_status']} "
|
|
83
|
+
f"(freshness={result.get('freshness_state', '?')} {result.get('freshness_score', 0)})"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def handle_claim_lint(max_age_days: int = 30, limit: int = 20) -> str:
|
|
88
|
+
items = claim_graph.lint_claims(max_age_days=max_age_days, limit=limit)
|
|
89
|
+
if not items:
|
|
90
|
+
return "Claim lint: no attention items."
|
|
91
|
+
lines = [f"CLAIM LINT — {len(items)} attention item(s):", ""]
|
|
92
|
+
for item in items:
|
|
93
|
+
lines.append(f" #{item['id']} [{', '.join(item.get('lint_reasons', []))}]")
|
|
94
|
+
lines.append(f" {item['text'][:220]}")
|
|
95
|
+
return "\n".join(lines)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def handle_claim_stats() -> str:
|
|
99
|
+
stats = claim_graph.stats()
|
|
100
|
+
return (
|
|
101
|
+
"CLAIM GRAPH STATS\n"
|
|
102
|
+
f" total: {stats['total_claims']}\n"
|
|
103
|
+
f" links: {stats['total_links']}\n"
|
|
104
|
+
f" contradictions: {stats['contradictions']}\n"
|
|
105
|
+
f" lint_attention: {stats['lint_attention']}\n"
|
|
106
|
+
f" by_status: {stats['by_status']}\n"
|
|
107
|
+
f" by_domain: {stats['by_domain']}"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
TOOLS = [
|
|
112
|
+
(handle_claim_add, "nexo_claim_add", "Add a structured claim with provenance, evidence, and freshness."),
|
|
113
|
+
(handle_claim_search, "nexo_claim_search", "Search claims by meaning or filters."),
|
|
114
|
+
(handle_claim_get, "nexo_claim_get", "Get a single claim with its links and freshness metadata."),
|
|
115
|
+
(handle_claim_link, "nexo_claim_link", "Link two claims with supports/contradicts/refines/supersedes."),
|
|
116
|
+
(handle_claim_verify, "nexo_claim_verify", "Verify or reclassify a claim state."),
|
|
117
|
+
(handle_claim_lint, "nexo_claim_lint", "Audit stale, weak, contradictory, or evidence-poor claims."),
|
|
118
|
+
(handle_claim_stats, "nexo_claim_stats", "Claim graph statistics and attention counts."),
|
|
119
|
+
]
|
|
@@ -21,6 +21,10 @@ def handle_cognitive_retrieve(
|
|
|
21
21
|
include_archived: bool = False,
|
|
22
22
|
use_hyde: bool | None = None,
|
|
23
23
|
spreading_depth: int | None = None,
|
|
24
|
+
hybrid_alpha: float = 0.6,
|
|
25
|
+
decompose: bool = True,
|
|
26
|
+
exclude_dreams: bool = True,
|
|
27
|
+
exclude_dormant: bool = True,
|
|
24
28
|
) -> str:
|
|
25
29
|
"""RAG query over cognitive memory (STM + LTM). Triggers rehearsal on retrieved memories.
|
|
26
30
|
|
|
@@ -34,6 +38,10 @@ def handle_cognitive_retrieve(
|
|
|
34
38
|
include_archived: If True, also search archived memories (default False)
|
|
35
39
|
use_hyde: If True/False, force HyDE on/off. If omitted, NEXO auto-enables it for conceptual queries.
|
|
36
40
|
spreading_depth: If >0, boost co-activated neighbors directly. If omitted, NEXO may auto-enable shallow spreading for multi-hop queries.
|
|
41
|
+
hybrid_alpha: Weight for vector vs BM25 fusion (default 0.6)
|
|
42
|
+
decompose: If True, decompose complex queries into sub-queries (default True)
|
|
43
|
+
exclude_dreams: If True, exclude dream insights from retrieval by default
|
|
44
|
+
exclude_dormant: If True, keep dormant LTM out of results unless explicitly requested
|
|
37
45
|
"""
|
|
38
46
|
if not query or not query.strip():
|
|
39
47
|
return "ERROR: query is required."
|
|
@@ -43,12 +51,15 @@ def handle_cognitive_retrieve(
|
|
|
43
51
|
top_k=top_k,
|
|
44
52
|
min_score=min_score,
|
|
45
53
|
stores=stores,
|
|
46
|
-
exclude_dormant=
|
|
54
|
+
exclude_dormant=exclude_dormant,
|
|
47
55
|
rehearse=True,
|
|
48
56
|
source_type_filter=source_type,
|
|
49
57
|
include_archived=include_archived,
|
|
50
58
|
use_hyde=use_hyde,
|
|
51
59
|
spreading_depth=spreading_depth,
|
|
60
|
+
hybrid_alpha=hybrid_alpha,
|
|
61
|
+
decompose=decompose,
|
|
62
|
+
exclude_dreams=exclude_dreams,
|
|
52
63
|
)
|
|
53
64
|
|
|
54
65
|
# Apply domain filter post-search (cognitive.search doesn't filter by domain natively)
|
|
@@ -65,6 +76,10 @@ def handle_cognitive_retrieve(
|
|
|
65
76
|
mode_parts.append(f"spreading={spreading_depth}")
|
|
66
77
|
elif spreading_depth is None:
|
|
67
78
|
mode_parts.append("spreading=AUTO")
|
|
79
|
+
mode_parts.append(f"hybrid_alpha={hybrid_alpha}")
|
|
80
|
+
mode_parts.append(f"decompose={'ON' if decompose else 'OFF'}")
|
|
81
|
+
mode_parts.append(f"dreams={'OFF' if exclude_dreams else 'ON'}")
|
|
82
|
+
mode_parts.append(f"dormant={'OFF' if exclude_dormant else 'ON'}")
|
|
68
83
|
if results:
|
|
69
84
|
top_score = float(results[0].get("score", 0.0) or 0.0)
|
|
70
85
|
confidence = "high" if top_score >= 0.82 else "medium" if top_score >= 0.66 else "low"
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Multimodal memory reference tools."""
|
|
2
|
+
|
|
3
|
+
import media_memory
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def handle_media_memory_add(
|
|
7
|
+
file_path: str = "",
|
|
8
|
+
url: str = "",
|
|
9
|
+
title: str = "",
|
|
10
|
+
description: str = "",
|
|
11
|
+
tags: str = "",
|
|
12
|
+
domain: str = "",
|
|
13
|
+
source_type: str = "",
|
|
14
|
+
source_id: str = "",
|
|
15
|
+
metadata: str = "",
|
|
16
|
+
) -> str:
|
|
17
|
+
result = media_memory.add_media_memory(
|
|
18
|
+
file_path=file_path,
|
|
19
|
+
url=url,
|
|
20
|
+
title=title,
|
|
21
|
+
description=description,
|
|
22
|
+
tags=tags,
|
|
23
|
+
domain=domain,
|
|
24
|
+
source_type=source_type,
|
|
25
|
+
source_id=source_id,
|
|
26
|
+
metadata=metadata,
|
|
27
|
+
)
|
|
28
|
+
if result.get("error"):
|
|
29
|
+
return f"ERROR: {result['error']}"
|
|
30
|
+
location = result.get("file_path") or result.get("url") or "n/a"
|
|
31
|
+
return f"Media memory #{result['id']} [{result['media_type']}] stored: {location}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def handle_media_memory_search(
|
|
35
|
+
query: str = "",
|
|
36
|
+
media_type: str = "",
|
|
37
|
+
domain: str = "",
|
|
38
|
+
tag: str = "",
|
|
39
|
+
limit: int = 20,
|
|
40
|
+
) -> str:
|
|
41
|
+
items = media_memory.search_media_memories(
|
|
42
|
+
query=query,
|
|
43
|
+
media_type=media_type,
|
|
44
|
+
domain=domain,
|
|
45
|
+
tag=tag,
|
|
46
|
+
limit=limit,
|
|
47
|
+
)
|
|
48
|
+
if not items:
|
|
49
|
+
return "No media memories found."
|
|
50
|
+
lines = [f"MEDIA MEMORIES — {len(items)} result(s):", ""]
|
|
51
|
+
for item in items:
|
|
52
|
+
lines.append(f" #{item['id']} [{item['media_type']}] {item['title'][:120]}")
|
|
53
|
+
lines.append(f" {item.get('file_path') or item.get('url') or 'n/a'}")
|
|
54
|
+
if item.get("description"):
|
|
55
|
+
lines.append(f" {item['description'][:180]}")
|
|
56
|
+
if item.get("tags"):
|
|
57
|
+
lines.append(f" tags: {item['tags']}")
|
|
58
|
+
return "\n".join(lines)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def handle_media_memory_get(media_id: int) -> str:
|
|
62
|
+
item = media_memory.get_media_memory(media_id)
|
|
63
|
+
if not item:
|
|
64
|
+
return f"Media memory #{media_id} not found."
|
|
65
|
+
lines = [
|
|
66
|
+
f"MEDIA MEMORY #{item['id']}",
|
|
67
|
+
f" type: {item['media_type']}",
|
|
68
|
+
f" title: {item['title']}",
|
|
69
|
+
f" location: {item.get('file_path') or item.get('url') or 'n/a'}",
|
|
70
|
+
f" domain: {item.get('domain') or 'n/a'}",
|
|
71
|
+
f" source: {item.get('source_type') or 'n/a'}:{item.get('source_id') or ''}",
|
|
72
|
+
]
|
|
73
|
+
if item.get("description"):
|
|
74
|
+
lines.append(f" description: {item['description']}")
|
|
75
|
+
if item.get("tags"):
|
|
76
|
+
lines.append(f" tags: {item['tags']}")
|
|
77
|
+
if item.get("metadata"):
|
|
78
|
+
lines.append(f" metadata: {item['metadata']}")
|
|
79
|
+
return "\n".join(lines)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def handle_media_memory_stats() -> str:
|
|
83
|
+
stats = media_memory.media_memory_stats()
|
|
84
|
+
return (
|
|
85
|
+
"MEDIA MEMORY STATS\n"
|
|
86
|
+
f" total: {stats['total']}\n"
|
|
87
|
+
f" backend: {stats['backend']}\n"
|
|
88
|
+
f" by_type: {stats['by_type']}\n"
|
|
89
|
+
f" by_domain: {stats['by_domain']}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
TOOLS = [
|
|
94
|
+
(handle_media_memory_add, "nexo_media_memory_add", "Store a non-text artifact as first-class media memory metadata."),
|
|
95
|
+
(handle_media_memory_search, "nexo_media_memory_search", "Search media memories by text, type, tag, or domain."),
|
|
96
|
+
(handle_media_memory_get, "nexo_media_memory_get", "Inspect one stored media memory."),
|
|
97
|
+
(handle_media_memory_stats, "nexo_media_memory_stats", "Stats for the multimodal/media memory layer."),
|
|
98
|
+
]
|