nexo-brain 3.1.8 → 3.2.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 +21 -0
- package/package.json +1 -1
- package/src/auto_update.py +27 -30
- package/src/scripts/deep-sleep/collect.py +6 -200
- package/src/server.py +41 -0
- package/src/system_catalog.py +419 -0
- package/src/tools_system_catalog.py +19 -0
- package/src/tools_transcripts.py +98 -0
- package/src/transcript_utils.py +412 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
"""Live system catalog / ontology derived from canonical NEXO sources."""
|
|
3
|
+
|
|
4
|
+
import ast
|
|
5
|
+
import importlib.util
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from db import get_db, list_skills, sync_skill_directories
|
|
12
|
+
from plugin_loader import PERSONAL_PLUGINS_DIR, PLUGINS_DIR, list_plugins
|
|
13
|
+
from script_registry import list_scripts
|
|
14
|
+
|
|
15
|
+
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
16
|
+
NEXO_CODE = Path(__file__).resolve().parent
|
|
17
|
+
SERVER_PATH = NEXO_CODE / "server.py"
|
|
18
|
+
MANIFEST_PATHS = [NEXO_CODE / "crons" / "manifest.json", NEXO_HOME / "crons" / "manifest.json"]
|
|
19
|
+
ATLAS_PATH = NEXO_HOME / "brain" / "project-atlas.json"
|
|
20
|
+
|
|
21
|
+
SECTION_ORDER = (
|
|
22
|
+
"core_tools",
|
|
23
|
+
"plugin_tools",
|
|
24
|
+
"skills",
|
|
25
|
+
"scripts",
|
|
26
|
+
"crons",
|
|
27
|
+
"projects",
|
|
28
|
+
"artifacts",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _normalize_text(text: str | None) -> str:
|
|
33
|
+
return str(text or "").strip().lower()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _tokenize(text: str | None) -> set[str]:
|
|
37
|
+
import re
|
|
38
|
+
normalized = _normalize_text(text)
|
|
39
|
+
return {
|
|
40
|
+
token
|
|
41
|
+
for token in re.findall(r"[a-z0-9][a-z0-9._:-]{1,}", normalized)
|
|
42
|
+
if len(token) >= 3
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _score(query_tokens: set[str], haystack: str) -> float:
|
|
47
|
+
if not query_tokens:
|
|
48
|
+
return 0.0
|
|
49
|
+
haystack_tokens = _tokenize(haystack)
|
|
50
|
+
if not haystack_tokens:
|
|
51
|
+
return 0.0
|
|
52
|
+
overlap = query_tokens & haystack_tokens
|
|
53
|
+
if not overlap:
|
|
54
|
+
return 0.0
|
|
55
|
+
return len(overlap) / max(1, min(len(query_tokens), len(haystack_tokens)))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _truncate(text: str | None, limit: int = 180) -> str:
|
|
59
|
+
clean = str(text or "").strip()
|
|
60
|
+
if len(clean) <= limit:
|
|
61
|
+
return clean
|
|
62
|
+
return clean[: limit - 3] + "..."
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _tool_category(name: str) -> str:
|
|
66
|
+
if name.startswith("nexo_recent_context") or name.startswith("nexo_pre_action_context") or name.startswith("nexo_hot_context"):
|
|
67
|
+
return "recent_memory"
|
|
68
|
+
if name.startswith("nexo_transcript"):
|
|
69
|
+
return "transcripts"
|
|
70
|
+
if name.startswith("nexo_session") or name.startswith("nexo_checkpoint"):
|
|
71
|
+
return "sessions"
|
|
72
|
+
if name.startswith("nexo_followup") or name.startswith("nexo_reminder"):
|
|
73
|
+
return "reminders"
|
|
74
|
+
if name.startswith("nexo_skill"):
|
|
75
|
+
return "skills"
|
|
76
|
+
if name.startswith("nexo_plugin"):
|
|
77
|
+
return "plugins"
|
|
78
|
+
if name.startswith("nexo_goal") or name.startswith("nexo_workflow"):
|
|
79
|
+
return "workflow"
|
|
80
|
+
if name.startswith("nexo_learning"):
|
|
81
|
+
return "learnings"
|
|
82
|
+
if name.startswith("nexo_guard") or name.startswith("nexo_task") or name.startswith("nexo_cortex"):
|
|
83
|
+
return "protocol"
|
|
84
|
+
return "general"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _parse_core_tools() -> list[dict]:
|
|
88
|
+
if not SERVER_PATH.is_file():
|
|
89
|
+
return []
|
|
90
|
+
try:
|
|
91
|
+
tree = ast.parse(SERVER_PATH.read_text())
|
|
92
|
+
except Exception:
|
|
93
|
+
return []
|
|
94
|
+
|
|
95
|
+
entries: list[dict] = []
|
|
96
|
+
for node in tree.body:
|
|
97
|
+
if not isinstance(node, ast.FunctionDef):
|
|
98
|
+
continue
|
|
99
|
+
if not any(
|
|
100
|
+
isinstance(dec, ast.Attribute) and getattr(dec.value, "id", "") == "mcp" and dec.attr == "tool"
|
|
101
|
+
for dec in node.decorator_list
|
|
102
|
+
):
|
|
103
|
+
continue
|
|
104
|
+
doc = ast.get_docstring(node) or ""
|
|
105
|
+
first_line = doc.strip().splitlines()[0].strip() if doc.strip() else ""
|
|
106
|
+
entries.append(
|
|
107
|
+
{
|
|
108
|
+
"kind": "core_tool",
|
|
109
|
+
"name": node.name,
|
|
110
|
+
"description": first_line,
|
|
111
|
+
"category": _tool_category(node.name),
|
|
112
|
+
"path": str(SERVER_PATH),
|
|
113
|
+
"line": int(getattr(node, "lineno", 0) or 0),
|
|
114
|
+
"source": "core",
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
return entries
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _plugin_module_tools(filename: str, created_by: str) -> dict[str, str]:
|
|
121
|
+
module_name = f"plugins.{filename[:-3]}"
|
|
122
|
+
module = sys.modules.get(module_name)
|
|
123
|
+
if module is None:
|
|
124
|
+
plugin_dir = PLUGINS_DIR if created_by == "repo" else PERSONAL_PLUGINS_DIR
|
|
125
|
+
path = Path(plugin_dir) / filename
|
|
126
|
+
if not path.is_file():
|
|
127
|
+
return {}
|
|
128
|
+
try:
|
|
129
|
+
spec = importlib.util.spec_from_file_location(module_name, path)
|
|
130
|
+
if spec is None or spec.loader is None:
|
|
131
|
+
return {}
|
|
132
|
+
module = importlib.util.module_from_spec(spec)
|
|
133
|
+
spec.loader.exec_module(module)
|
|
134
|
+
except Exception:
|
|
135
|
+
return {}
|
|
136
|
+
tools = getattr(module, "TOOLS", []) or []
|
|
137
|
+
result: dict[str, str] = {}
|
|
138
|
+
for item in tools:
|
|
139
|
+
try:
|
|
140
|
+
_, name, description = item
|
|
141
|
+
except Exception:
|
|
142
|
+
continue
|
|
143
|
+
result[str(name)] = str(description or "")
|
|
144
|
+
return result
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _plugin_entries() -> list[dict]:
|
|
148
|
+
rows = list_plugins()
|
|
149
|
+
entries: list[dict] = []
|
|
150
|
+
for row in rows:
|
|
151
|
+
filename = str(row.get("filename") or "")
|
|
152
|
+
created_by = str(row.get("created_by") or row.get("source") or "repo")
|
|
153
|
+
descriptions = _plugin_module_tools(filename, created_by)
|
|
154
|
+
names = str(row.get("tool_names") or "").split(",")
|
|
155
|
+
for name in [n.strip() for n in names if n.strip()]:
|
|
156
|
+
entries.append(
|
|
157
|
+
{
|
|
158
|
+
"kind": "plugin_tool",
|
|
159
|
+
"name": name,
|
|
160
|
+
"description": descriptions.get(name, ""),
|
|
161
|
+
"plugin": filename,
|
|
162
|
+
"source": created_by,
|
|
163
|
+
"category": _tool_category(name),
|
|
164
|
+
}
|
|
165
|
+
)
|
|
166
|
+
return entries
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _skill_entries() -> list[dict]:
|
|
170
|
+
try:
|
|
171
|
+
sync_skill_directories()
|
|
172
|
+
except Exception:
|
|
173
|
+
pass
|
|
174
|
+
entries: list[dict] = []
|
|
175
|
+
for row in list_skills():
|
|
176
|
+
entries.append(
|
|
177
|
+
{
|
|
178
|
+
"kind": "skill",
|
|
179
|
+
"name": row.get("id", ""),
|
|
180
|
+
"display_name": row.get("name", ""),
|
|
181
|
+
"description": row.get("description", "") or "",
|
|
182
|
+
"source": row.get("source_kind", "") or "",
|
|
183
|
+
"level": row.get("level", "") or "",
|
|
184
|
+
"mode": row.get("mode", "") or "",
|
|
185
|
+
"execution_level": row.get("execution_level", "") or "",
|
|
186
|
+
"trust_score": row.get("trust_score", 0),
|
|
187
|
+
"tags": row.get("tags", "[]"),
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
return entries
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _script_entries() -> list[dict]:
|
|
194
|
+
entries: list[dict] = []
|
|
195
|
+
for row in list_scripts(include_core=True):
|
|
196
|
+
entries.append(
|
|
197
|
+
{
|
|
198
|
+
"kind": "script",
|
|
199
|
+
"name": row.get("name", ""),
|
|
200
|
+
"description": row.get("description", "") or "",
|
|
201
|
+
"runtime": row.get("runtime", "") or "",
|
|
202
|
+
"path": row.get("path", "") or "",
|
|
203
|
+
"source": "core" if row.get("core") else "personal",
|
|
204
|
+
"classification": row.get("classification", "") or "",
|
|
205
|
+
"declared_schedule": row.get("declared_schedule", {}) or {},
|
|
206
|
+
}
|
|
207
|
+
)
|
|
208
|
+
return entries
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _cron_entries() -> list[dict]:
|
|
212
|
+
manifest = None
|
|
213
|
+
for path in MANIFEST_PATHS:
|
|
214
|
+
if path.is_file():
|
|
215
|
+
try:
|
|
216
|
+
manifest = json.loads(path.read_text())
|
|
217
|
+
break
|
|
218
|
+
except Exception:
|
|
219
|
+
continue
|
|
220
|
+
if not isinstance(manifest, dict):
|
|
221
|
+
return []
|
|
222
|
+
entries: list[dict] = []
|
|
223
|
+
for cron in manifest.get("crons", []) or []:
|
|
224
|
+
entries.append(
|
|
225
|
+
{
|
|
226
|
+
"kind": "cron",
|
|
227
|
+
"name": cron.get("id", ""),
|
|
228
|
+
"description": cron.get("description", "") or "",
|
|
229
|
+
"script": cron.get("script", "") or "",
|
|
230
|
+
"schedule": cron.get("schedule", {}) or {},
|
|
231
|
+
"optional": bool(cron.get("optional", False)),
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
return entries
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _project_entries() -> list[dict]:
|
|
238
|
+
if not ATLAS_PATH.is_file():
|
|
239
|
+
return []
|
|
240
|
+
try:
|
|
241
|
+
payload = json.loads(ATLAS_PATH.read_text())
|
|
242
|
+
except Exception:
|
|
243
|
+
return []
|
|
244
|
+
entries: list[dict] = []
|
|
245
|
+
if isinstance(payload, dict):
|
|
246
|
+
for key, value in payload.items():
|
|
247
|
+
if str(key).startswith("_"):
|
|
248
|
+
continue
|
|
249
|
+
if not isinstance(value, dict):
|
|
250
|
+
continue
|
|
251
|
+
entries.append(
|
|
252
|
+
{
|
|
253
|
+
"kind": "project",
|
|
254
|
+
"name": key,
|
|
255
|
+
"path": value.get("path", "") or "",
|
|
256
|
+
"domain": value.get("domain", "") or "",
|
|
257
|
+
"aliases": value.get("aliases", []) or [],
|
|
258
|
+
"services": value.get("services", {}) or {},
|
|
259
|
+
"plugins": value.get("plugins", "") or value.get("plugin_path", "") or "",
|
|
260
|
+
}
|
|
261
|
+
)
|
|
262
|
+
return entries
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _artifact_entries() -> list[dict]:
|
|
266
|
+
conn = get_db()
|
|
267
|
+
try:
|
|
268
|
+
rows = conn.execute(
|
|
269
|
+
"SELECT canonical_name, kind, domain, state, uri, paths, ports, aliases FROM artifact_registry ORDER BY last_touched_at DESC LIMIT 100"
|
|
270
|
+
).fetchall()
|
|
271
|
+
except Exception:
|
|
272
|
+
return []
|
|
273
|
+
return [
|
|
274
|
+
{
|
|
275
|
+
"kind": "artifact",
|
|
276
|
+
"name": row["canonical_name"],
|
|
277
|
+
"artifact_kind": row["kind"],
|
|
278
|
+
"domain": row["domain"],
|
|
279
|
+
"state": row["state"],
|
|
280
|
+
"uri": row["uri"],
|
|
281
|
+
"paths": row["paths"],
|
|
282
|
+
"ports": row["ports"],
|
|
283
|
+
"aliases": row["aliases"],
|
|
284
|
+
}
|
|
285
|
+
for row in rows
|
|
286
|
+
]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def build_system_catalog() -> dict:
|
|
290
|
+
catalog = {
|
|
291
|
+
"core_tools": _parse_core_tools(),
|
|
292
|
+
"plugin_tools": _plugin_entries(),
|
|
293
|
+
"skills": _skill_entries(),
|
|
294
|
+
"scripts": _script_entries(),
|
|
295
|
+
"crons": _cron_entries(),
|
|
296
|
+
"projects": _project_entries(),
|
|
297
|
+
"artifacts": _artifact_entries(),
|
|
298
|
+
}
|
|
299
|
+
catalog["summary"] = {
|
|
300
|
+
section: len(catalog.get(section) or [])
|
|
301
|
+
for section in SECTION_ORDER
|
|
302
|
+
}
|
|
303
|
+
return catalog
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def search_system_catalog(query: str, *, section: str = "", limit: int = 20) -> list[dict]:
|
|
307
|
+
catalog = build_system_catalog()
|
|
308
|
+
query_tokens = _tokenize(query)
|
|
309
|
+
sections = [section] if section in SECTION_ORDER else list(SECTION_ORDER)
|
|
310
|
+
matches: list[dict] = []
|
|
311
|
+
for section_name in sections:
|
|
312
|
+
for entry in catalog.get(section_name) or []:
|
|
313
|
+
haystack = " ".join(
|
|
314
|
+
[
|
|
315
|
+
section_name,
|
|
316
|
+
str(entry.get("name", "") or ""),
|
|
317
|
+
str(entry.get("display_name", "") or ""),
|
|
318
|
+
str(entry.get("description", "") or ""),
|
|
319
|
+
str(entry.get("source", "") or ""),
|
|
320
|
+
str(entry.get("category", "") or ""),
|
|
321
|
+
str(entry.get("plugin", "") or ""),
|
|
322
|
+
str(entry.get("domain", "") or ""),
|
|
323
|
+
str(entry.get("path", "") or ""),
|
|
324
|
+
json.dumps(entry, ensure_ascii=False),
|
|
325
|
+
]
|
|
326
|
+
)
|
|
327
|
+
score = _score(query_tokens, haystack) if query_tokens else 0.5
|
|
328
|
+
if query_tokens and score <= 0:
|
|
329
|
+
continue
|
|
330
|
+
row = dict(entry)
|
|
331
|
+
row["_section"] = section_name
|
|
332
|
+
row["_score"] = round(score, 4)
|
|
333
|
+
matches.append(row)
|
|
334
|
+
matches.sort(key=lambda row: (row["_score"], row.get("name", "")), reverse=True)
|
|
335
|
+
return matches[: max(1, int(limit or 20))]
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def explain_tool(name: str) -> dict | None:
|
|
339
|
+
clean = _normalize_text(name)
|
|
340
|
+
if not clean:
|
|
341
|
+
return None
|
|
342
|
+
exact = search_system_catalog(clean, limit=200)
|
|
343
|
+
for row in exact:
|
|
344
|
+
if _normalize_text(row.get("name")) == clean:
|
|
345
|
+
return row
|
|
346
|
+
for row in exact:
|
|
347
|
+
if clean in _normalize_text(row.get("name")):
|
|
348
|
+
return row
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def format_catalog(catalog: dict, *, section: str = "", query: str = "", limit: int = 20) -> str:
|
|
353
|
+
summary = catalog.get("summary") or {}
|
|
354
|
+
if query:
|
|
355
|
+
matches = search_system_catalog(query, section=section, limit=limit)
|
|
356
|
+
if not matches:
|
|
357
|
+
scope = section or "all sections"
|
|
358
|
+
return f"No system-catalog matches for '{query}' in {scope}."
|
|
359
|
+
lines = [f"SYSTEM CATALOG SEARCH — '{query}' ({len(matches)} match(es))"]
|
|
360
|
+
for row in matches:
|
|
361
|
+
label = row.get("_section", "")
|
|
362
|
+
title = row.get("display_name") or row.get("name") or "(unnamed)"
|
|
363
|
+
desc = _truncate(row.get("description") or row.get("path") or row.get("script") or "", 180)
|
|
364
|
+
suffix = f" — {desc}" if desc else ""
|
|
365
|
+
lines.append(f"- [{label}] {title}{suffix}")
|
|
366
|
+
return "\n".join(lines)
|
|
367
|
+
|
|
368
|
+
if section in SECTION_ORDER:
|
|
369
|
+
entries = catalog.get(section) or []
|
|
370
|
+
if not entries:
|
|
371
|
+
return f"SYSTEM CATALOG — {section}: empty"
|
|
372
|
+
lines = [f"SYSTEM CATALOG — {section} ({len(entries)})"]
|
|
373
|
+
for row in entries[: max(1, int(limit or 20))]:
|
|
374
|
+
title = row.get("display_name") or row.get("name") or "(unnamed)"
|
|
375
|
+
desc = _truncate(row.get("description") or row.get("path") or row.get("script") or "", 180)
|
|
376
|
+
suffix = f" — {desc}" if desc else ""
|
|
377
|
+
lines.append(f"- {title}{suffix}")
|
|
378
|
+
return "\n".join(lines)
|
|
379
|
+
|
|
380
|
+
lines = ["SYSTEM CATALOG SUMMARY"]
|
|
381
|
+
for name in SECTION_ORDER:
|
|
382
|
+
lines.append(f"- {name}: {summary.get(name, 0)}")
|
|
383
|
+
return "\n".join(lines)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def format_tool_explanation(entry: dict | None) -> str:
|
|
387
|
+
if not entry:
|
|
388
|
+
return "Tool/capability not found in the live system catalog."
|
|
389
|
+
lines = [
|
|
390
|
+
f"CATALOG ENTRY — {entry.get('name') or entry.get('display_name')}",
|
|
391
|
+
f"Section: {entry.get('_section') or entry.get('kind')}",
|
|
392
|
+
]
|
|
393
|
+
if entry.get("display_name"):
|
|
394
|
+
lines.append(f"Display name: {entry['display_name']}")
|
|
395
|
+
if entry.get("description"):
|
|
396
|
+
lines.append(f"Description: {entry['description']}")
|
|
397
|
+
if entry.get("category"):
|
|
398
|
+
lines.append(f"Category: {entry['category']}")
|
|
399
|
+
if entry.get("source"):
|
|
400
|
+
lines.append(f"Source: {entry['source']}")
|
|
401
|
+
if entry.get("plugin"):
|
|
402
|
+
lines.append(f"Plugin: {entry['plugin']}")
|
|
403
|
+
if entry.get("path"):
|
|
404
|
+
lines.append(f"Path: {entry['path']}")
|
|
405
|
+
if entry.get("line"):
|
|
406
|
+
lines.append(f"Line: {entry['line']}")
|
|
407
|
+
if entry.get("script"):
|
|
408
|
+
lines.append(f"Script: {entry['script']}")
|
|
409
|
+
if entry.get("runtime"):
|
|
410
|
+
lines.append(f"Runtime: {entry['runtime']}")
|
|
411
|
+
if entry.get("level"):
|
|
412
|
+
lines.append(f"Level: {entry['level']}")
|
|
413
|
+
if entry.get("mode"):
|
|
414
|
+
lines.append(f"Mode: {entry['mode']}")
|
|
415
|
+
if entry.get("execution_level"):
|
|
416
|
+
lines.append(f"Execution level: {entry['execution_level']}")
|
|
417
|
+
if entry.get("domain"):
|
|
418
|
+
lines.append(f"Domain: {entry['domain']}")
|
|
419
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Public MCP tools for the live NEXO system catalog / ontology."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from system_catalog import build_system_catalog, explain_tool, format_catalog, format_tool_explanation
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def handle_system_catalog(section: str = "", query: str = "", limit: int = 20) -> str:
|
|
9
|
+
catalog = build_system_catalog()
|
|
10
|
+
return format_catalog(
|
|
11
|
+
catalog,
|
|
12
|
+
section=(section or "").strip(),
|
|
13
|
+
query=(query or "").strip(),
|
|
14
|
+
limit=max(1, int(limit or 20)),
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def handle_tool_explain(name: str) -> str:
|
|
19
|
+
return format_tool_explanation(explain_tool(name))
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Public MCP tools for transcript fallback access."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from transcript_utils import (
|
|
6
|
+
clamp_transcript_hours,
|
|
7
|
+
list_recent_transcripts,
|
|
8
|
+
load_transcript,
|
|
9
|
+
search_transcripts,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def handle_transcript_search(query: str = "", hours: int = 24, client: str = "", limit: int = 10) -> str:
|
|
14
|
+
"""Search recent Claude Code / Codex transcripts as a fallback when memory is insufficient."""
|
|
15
|
+
window = clamp_transcript_hours(hours)
|
|
16
|
+
rows = search_transcripts(query or "", hours=window, client=(client or "").strip(), limit=limit)
|
|
17
|
+
if not rows:
|
|
18
|
+
scope = f"query='{query}'" if query else "recent transcripts"
|
|
19
|
+
return f"No transcript matches for {scope} in the last {window}h."
|
|
20
|
+
|
|
21
|
+
lines = [f"TRANSCRIPTS ({len(rows)}) — last {window}h"]
|
|
22
|
+
for item in rows:
|
|
23
|
+
lines.append(
|
|
24
|
+
f"- {item.get('session_file')}: [{item.get('client')}] {item.get('display_name')} "
|
|
25
|
+
f"(modified={item.get('modified')}, messages={item.get('message_count')}, user={item.get('user_message_count')})"
|
|
26
|
+
)
|
|
27
|
+
if item.get("cwd"):
|
|
28
|
+
lines.append(f" cwd: {item['cwd']}")
|
|
29
|
+
if item.get("session_uid"):
|
|
30
|
+
lines.append(f" session_uid: {item['session_uid']}")
|
|
31
|
+
for snippet in item.get("matched_messages") or []:
|
|
32
|
+
lines.append(
|
|
33
|
+
f" [{snippet.get('role')}#{snippet.get('index')}] {snippet.get('snippet')}"
|
|
34
|
+
)
|
|
35
|
+
return "\n".join(lines)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def handle_transcript_recent(hours: int = 24, client: str = "", limit: int = 10) -> str:
|
|
39
|
+
"""List recent transcripts without searching full text."""
|
|
40
|
+
window = clamp_transcript_hours(hours)
|
|
41
|
+
rows = list_recent_transcripts(hours=window, client=(client or "").strip(), limit=limit)
|
|
42
|
+
if not rows:
|
|
43
|
+
return f"No transcripts found in the last {window}h."
|
|
44
|
+
|
|
45
|
+
lines = [f"RECENT TRANSCRIPTS ({len(rows)}) — last {window}h"]
|
|
46
|
+
for item in rows:
|
|
47
|
+
lines.append(
|
|
48
|
+
f"- {item.get('session_file')}: [{item.get('client')}] {item.get('display_name')} "
|
|
49
|
+
f"(modified={item.get('modified')}, messages={item.get('message_count')}, user={item.get('user_message_count')})"
|
|
50
|
+
)
|
|
51
|
+
return "\n".join(lines)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def handle_transcript_read(
|
|
55
|
+
session_ref: str = "",
|
|
56
|
+
transcript_path: str = "",
|
|
57
|
+
client: str = "",
|
|
58
|
+
max_messages: int = 80,
|
|
59
|
+
) -> str:
|
|
60
|
+
"""Read a transcript in fallback mode. Accepts session_file, display name, session_uid or exact path."""
|
|
61
|
+
transcript = load_transcript(
|
|
62
|
+
session_ref=(session_ref or "").strip(),
|
|
63
|
+
transcript_path=(transcript_path or "").strip(),
|
|
64
|
+
client=(client or "").strip(),
|
|
65
|
+
)
|
|
66
|
+
if not transcript:
|
|
67
|
+
target = session_ref or transcript_path or "(empty ref)"
|
|
68
|
+
return f"Transcript not found for {target}."
|
|
69
|
+
|
|
70
|
+
limit = max(1, min(int(max_messages or 80), 200))
|
|
71
|
+
messages = transcript.get("messages") or []
|
|
72
|
+
truncated = len(messages) > limit
|
|
73
|
+
visible = messages[-limit:] if truncated else messages
|
|
74
|
+
|
|
75
|
+
lines = [
|
|
76
|
+
f"TRANSCRIPT {transcript.get('session_file')}",
|
|
77
|
+
f"Client: {transcript.get('client')}",
|
|
78
|
+
f"Display: {transcript.get('display_name')}",
|
|
79
|
+
f"Path: {transcript.get('session_path')}",
|
|
80
|
+
f"Modified: {transcript.get('modified')}",
|
|
81
|
+
f"Messages: {transcript.get('message_count')} (user={transcript.get('user_message_count')}, tools={transcript.get('tool_use_count')})",
|
|
82
|
+
]
|
|
83
|
+
if transcript.get("cwd"):
|
|
84
|
+
lines.append(f"CWD: {transcript.get('cwd')}")
|
|
85
|
+
if transcript.get("session_uid"):
|
|
86
|
+
lines.append(f"Session UID: {transcript.get('session_uid')}")
|
|
87
|
+
if truncated:
|
|
88
|
+
lines.append(f"Showing last {limit} messages.")
|
|
89
|
+
|
|
90
|
+
for message in visible:
|
|
91
|
+
role = str(message.get("role") or "?").upper()
|
|
92
|
+
index = message.get("index", "?")
|
|
93
|
+
text = str(message.get("text") or "").strip()
|
|
94
|
+
lines.append("")
|
|
95
|
+
lines.append(f"[{role} #{index}]")
|
|
96
|
+
lines.append(text)
|
|
97
|
+
|
|
98
|
+
return "\n".join(lines)
|