@marsnme/mcp-gateway 0.1.1

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.
@@ -0,0 +1,779 @@
1
+ #!/usr/bin/env python3
2
+ import base64
3
+ import json
4
+ import os
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ import tarfile
9
+ import tempfile
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from typing import Optional
13
+ from urllib import error, request
14
+
15
+ def getenv_first(names: list[str], default: str = "") -> str:
16
+ for name in names:
17
+ value = os.getenv(name)
18
+ if value is not None:
19
+ return value
20
+ return default
21
+
22
+ def env_bool_any(names: list[str], default: bool) -> bool:
23
+ raw = getenv_first(names, "")
24
+ if raw is None or raw == "":
25
+ return default
26
+ normalized = raw.strip().lower()
27
+ if normalized in {"1", "true", "yes", "on"}:
28
+ return True
29
+ if normalized in {"0", "false", "no", "off"}:
30
+ return False
31
+ return default
32
+
33
+ def env_int_any(
34
+ names: list[str],
35
+ default: int,
36
+ minimum: Optional[int] = None,
37
+ maximum: Optional[int] = None
38
+ ) -> int:
39
+ raw = getenv_first(names, "").strip()
40
+ value = default
41
+ if raw:
42
+ try:
43
+ value = int(raw)
44
+ except ValueError:
45
+ value = default
46
+ if minimum is not None:
47
+ value = max(minimum, value)
48
+ if maximum is not None:
49
+ value = min(maximum, value)
50
+ return value
51
+
52
+ def normalize_line(text: str, limit: int = 220) -> str:
53
+ cleaned = re.sub(r"\s+", " ", text or "").strip()
54
+ if len(cleaned) <= limit:
55
+ return cleaned
56
+ return cleaned[: limit - 1].rstrip() + "…"
57
+
58
+ def load_env_file(path: str):
59
+ env_path = Path(path)
60
+ if not env_path.exists():
61
+ return
62
+ for line in env_path.read_text(encoding="utf-8", errors="ignore").splitlines():
63
+ raw = line.strip()
64
+ if not raw or raw.startswith("#") or "=" not in raw:
65
+ continue
66
+ key, value = raw.split("=", 1)
67
+ key = key.strip()
68
+ if key and key not in os.environ:
69
+ os.environ[key] = value.strip()
70
+
71
+ def infer_profile() -> str:
72
+ explicit = getenv_first(
73
+ ["DREAM_DIGEST_PROFILE", "HERMES_DIGEST_PROFILE"],
74
+ ""
75
+ ).strip().lower()
76
+ if explicit in {"coco", "toto"}:
77
+ return explicit
78
+ script_path = str(Path(__file__)).lower()
79
+ if "/profiles/toto/" in script_path:
80
+ return "toto"
81
+ if "/profiles/coco/" in script_path:
82
+ return "coco"
83
+ mcp_hint = getenv_first(
84
+ ["DREAM_DIGEST_MCP_URL", "HERMES_DIGEST_MCP_URL"],
85
+ ""
86
+ ).strip()
87
+ if ":18791" in mcp_hint:
88
+ return "toto"
89
+ return "coco"
90
+
91
+ PROFILE = infer_profile()
92
+ PROFILE_LABEL = {"coco": "CoCo", "toto": "Toto"}.get(PROFILE, "CoCo")
93
+ RUNNER_MODE = getenv_first(["DREAM_MODE"], "standard").strip().lower() or "standard"
94
+ if RUNNER_MODE not in {"lite", "standard", "pro"}:
95
+ RUNNER_MODE = "standard"
96
+
97
+ DEFAULT_DREAM_HOME = str(Path.home() / ".dream-runner")
98
+ DREAM_HOME = getenv_first(["DREAM_HOME", "HERMES_HOME"], DEFAULT_DREAM_HOME).strip() or DEFAULT_DREAM_HOME
99
+ DREAM_PROFILE_ROOT = (
100
+ getenv_first(["DREAM_PROFILE_ROOT", "HERMES_PROFILE_ROOT"], f"{DREAM_HOME}/profiles").strip()
101
+ or f"{DREAM_HOME}/profiles"
102
+ )
103
+ DREAM_CANONICAL_ROOT = (
104
+ getenv_first(["DREAM_CANONICAL_ROOT", "HERMES_CANONICAL_ROOT"], f"{DREAM_HOME}/canonical/AgentConfig").strip()
105
+ or f"{DREAM_HOME}/canonical/AgentConfig"
106
+ )
107
+ DREAM_ENV_FILE = getenv_first(["DREAM_ENV_FILE", "HERMES_ENV_FILE"], f"{DREAM_HOME}/.env").strip() or f"{DREAM_HOME}/.env"
108
+ DREAM_PROFILE_ENV_FILE = (
109
+ getenv_first(
110
+ ["DREAM_PROFILE_ENV_FILE", "HERMES_PROFILE_ENV_FILE"],
111
+ f"{DREAM_PROFILE_ROOT}/{PROFILE}/.env"
112
+ ).strip()
113
+ or f"{DREAM_PROFILE_ROOT}/{PROFILE}/.env"
114
+ )
115
+ load_env_file(DREAM_ENV_FILE)
116
+ load_env_file(DREAM_PROFILE_ENV_FILE)
117
+
118
+ DREAM_ENABLED = env_bool_any(["DREAM_ENABLED", "HERMES_ENABLED"], False)
119
+ DIGEST_BODY = getenv_first(["DREAM_DIGEST_BODY", "HERMES_DIGEST_BODY"], PROFILE).strip().lower()
120
+ if DIGEST_BODY not in {"coco", "toto", "system"}:
121
+ DIGEST_BODY = PROFILE
122
+
123
+ DEFAULT_MCP_URL = "http://127.0.0.1:18791/mcp" if PROFILE == "toto" else "http://127.0.0.1:18790/mcp"
124
+ DEFAULT_SOURCE_DIR = "Dream/TotoDigest" if DIGEST_BODY == "toto" else "Dream/Digest"
125
+ MCP_URL = getenv_first(["DREAM_DIGEST_MCP_URL", "HERMES_DIGEST_MCP_URL"], DEFAULT_MCP_URL)
126
+ MCP_BEARER_TOKEN = getenv_first(
127
+ ["DREAM_MCP_BEARER_TOKEN", "DREAM_DIGEST_MCP_BEARER_TOKEN", "HERMES_DIGEST_MCP_BEARER_TOKEN"],
128
+ ""
129
+ ).strip()
130
+ DIGEST_SOURCE_DIR = getenv_first(
131
+ ["DREAM_DIGEST_SOURCE_DIR", "HERMES_DIGEST_SOURCE_DIR"],
132
+ DEFAULT_SOURCE_DIR
133
+ ).strip().strip("/")
134
+ if not DIGEST_SOURCE_DIR:
135
+ DIGEST_SOURCE_DIR = DEFAULT_SOURCE_DIR
136
+
137
+ DIGEST_ORIGIN = getenv_first(
138
+ ["DREAM_DIGEST_ORIGIN", "HERMES_DIGEST_ORIGIN"],
139
+ f"dream-{DIGEST_BODY}-digest"
140
+ ).strip() or f"dream-{DIGEST_BODY}-digest"
141
+
142
+ GITHUB_API_BASE = getenv_first(["DREAM_GITHUB_API_BASE", "HERMES_GITHUB_API_BASE"], "https://api.github.com")
143
+ GITHUB_OWNER = getenv_first(["DREAM_GITHUB_OWNER", "HERMES_GITHUB_OWNER"], "").strip()
144
+ GITHUB_REPO = getenv_first(["DREAM_GITHUB_REPO", "HERMES_GITHUB_REPO"], "").strip()
145
+ GITHUB_GIT_USERNAME = getenv_first(
146
+ ["DREAM_GITHUB_GIT_USERNAME", "HERMES_GITHUB_GIT_USERNAME"],
147
+ GITHUB_OWNER
148
+ ).strip()
149
+ ISSUE_NUMBER = env_int_any(["DREAM_ISSUE_NUMBER", "HERMES_DIGEST_ISSUE_NUMBER"], 0, minimum=0)
150
+ ISSUE_API = getenv_first(["DREAM_ISSUE_API", "HERMES_DIGEST_ISSUE_API"], "").strip()
151
+ if not ISSUE_API and GITHUB_OWNER and GITHUB_REPO and ISSUE_NUMBER > 0:
152
+ ISSUE_API = f"{GITHUB_API_BASE}/repos/{GITHUB_OWNER}/{GITHUB_REPO}/issues/{ISSUE_NUMBER}"
153
+ ISSUES_LIMIT = env_int_any(["DREAM_ISSUES_LIMIT", "HERMES_DIGEST_ISSUES_LIMIT"], 6, minimum=1, maximum=20)
154
+
155
+ MARSVAULT_PATH = os.getenv("MARSVAULT_PATH", "").strip()
156
+ REPO_LOCAL_PATH = (
157
+ getenv_first(
158
+ ["DREAM_REPO_LOCAL_PATH", "HERMES_REPO_LOCAL_PATH"],
159
+ MARSVAULT_PATH
160
+ or (f"{DREAM_HOME}/repos/{GITHUB_REPO}" if GITHUB_REPO else "")
161
+ ).strip()
162
+ )
163
+ REPO_REMOTE_URL = getenv_first(["DREAM_REPO_REMOTE_URL", "HERMES_REPO_REMOTE_URL"], "").strip()
164
+ if not REPO_REMOTE_URL and GITHUB_OWNER and GITHUB_REPO:
165
+ REPO_REMOTE_URL = f"https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}.git"
166
+ REPO_LABEL = f"{GITHUB_OWNER}/{GITHUB_REPO}" if GITHUB_OWNER and GITHUB_REPO else "configured-repo"
167
+
168
+ DEFAULT_REPO_SCAN_KEYWORDS = (
169
+ f"toto,dream,memory,{(GITHUB_REPO or 'project').lower()},issue,digest,workflow"
170
+ if DIGEST_BODY == "toto"
171
+ else f"coco,dream,toto,memory,{(GITHUB_REPO or 'project').lower()},issue,digest,workflow"
172
+ )
173
+ REPO_KEYWORDS = [
174
+ keyword.strip().lower()
175
+ for keyword in getenv_first(
176
+ ["DREAM_REPO_SCAN_KEYWORDS", "HERMES_REPO_SCAN_KEYWORDS"],
177
+ DEFAULT_REPO_SCAN_KEYWORDS,
178
+ ).split(",")
179
+ if keyword.strip()
180
+ ]
181
+
182
+ REPO_FULL_SCAN_TOP_FILES = env_int_any(
183
+ ["DREAM_REPO_FULL_SCAN_TOP_FILES", "HERMES_REPO_FULL_SCAN_TOP_FILES"],
184
+ 120,
185
+ minimum=10,
186
+ maximum=400
187
+ )
188
+ SEMANTIC_MEMORY_LIMIT = env_int_any(
189
+ ["DREAM_SEMANTIC_LIMIT", "HERMES_DIGEST_SEMANTIC_LIMIT"],
190
+ 6,
191
+ minimum=1,
192
+ maximum=20
193
+ )
194
+ MEMORY_LIMIT = env_int_any(
195
+ ["DREAM_MEMORY_LIMIT", "HERMES_DIGEST_MEMORY_LIMIT"],
196
+ 20,
197
+ minimum=1,
198
+ maximum=100
199
+ )
200
+ CHUNK_CHARS = env_int_any(
201
+ ["DREAM_MAX_CHUNK_CHARS", "HERMES_DIGEST_MAX_CHUNK_CHARS"],
202
+ 1200,
203
+ minimum=300,
204
+ maximum=3000
205
+ )
206
+
207
+ MODE_DEFAULTS = {
208
+ "lite": {
209
+ "recent_memory": True,
210
+ "semantic_memory": True,
211
+ "issue_signals": False,
212
+ "repo_scan": False,
213
+ "soul_context": False
214
+ },
215
+ "standard": {
216
+ "recent_memory": True,
217
+ "semantic_memory": True,
218
+ "issue_signals": False,
219
+ "repo_scan": True,
220
+ "soul_context": True
221
+ },
222
+ "pro": {
223
+ "recent_memory": True,
224
+ "semantic_memory": True,
225
+ "issue_signals": True,
226
+ "repo_scan": True,
227
+ "soul_context": True
228
+ }
229
+ }
230
+ mode_default = MODE_DEFAULTS[RUNNER_MODE]
231
+
232
+ ENABLE_RECENT_MEMORY = env_bool_any(["DREAM_ENABLE_RECENT_MEMORY"], mode_default["recent_memory"])
233
+ ENABLE_SEMANTIC_MEMORY = env_bool_any(["DREAM_ENABLE_SEMANTIC_MEMORY"], mode_default["semantic_memory"])
234
+ ENABLE_ISSUE_SIGNALS = env_bool_any(["DREAM_ENABLE_ISSUE_SIGNALS"], mode_default["issue_signals"])
235
+ REPO_SCAN_ENABLED = env_bool_any(
236
+ ["DREAM_ENABLE_REPO_SCAN", "DREAM_REPO_SCAN_ENABLED", "HERMES_REPO_SCAN_ENABLED"],
237
+ mode_default["repo_scan"]
238
+ )
239
+ ENABLE_SOUL_CONTEXT = env_bool_any(["DREAM_ENABLE_SOUL_CONTEXT"], mode_default["soul_context"])
240
+
241
+ GITHUB_TOKEN = ""
242
+ canonical_profile = DIGEST_BODY if DIGEST_BODY in {"coco", "toto"} else PROFILE
243
+ DEFAULT_SOUL_PATHS = (
244
+ [
245
+ Path(f"{DREAM_PROFILE_ROOT}/toto/SOUL.md"),
246
+ Path(f"{DREAM_CANONICAL_ROOT}/toto/SOUL.md"),
247
+ ]
248
+ if DIGEST_BODY == "toto"
249
+ else [
250
+ Path(f"{DREAM_CANONICAL_ROOT}/{canonical_profile}/SOUL.md"),
251
+ Path(f"{DREAM_PROFILE_ROOT}/{PROFILE}/SOUL.md"),
252
+ ]
253
+ )
254
+ SOUL_PATHS_RAW = getenv_first(["DREAM_SOUL_PATHS", "HERMES_DIGEST_SOUL_PATHS"], "").strip()
255
+ SOUL_PATHS = (
256
+ [Path(item.strip()) for item in SOUL_PATHS_RAW.split(",") if item.strip()]
257
+ if SOUL_PATHS_RAW
258
+ else DEFAULT_SOUL_PATHS
259
+ )
260
+
261
+ def parse_memory_queries() -> list[str]:
262
+ raw = getenv_first(
263
+ ["DREAM_MEMORY_QUERIES", "HERMES_DIGEST_MEMORY_QUERIES"],
264
+ ""
265
+ ).strip()
266
+ if raw:
267
+ parts = [item.strip() for item in raw.split("||")]
268
+ values = [item for item in parts if item]
269
+ if values:
270
+ return values[:8]
271
+ profile_topic = "toto" if DIGEST_BODY == "toto" else "coco"
272
+ issue_topic = (
273
+ f"Issue {ISSUE_NUMBER} dream digest"
274
+ if ISSUE_NUMBER > 0
275
+ else "dream digest status"
276
+ )
277
+ repo_topic = GITHUB_REPO or "project"
278
+ return [
279
+ issue_topic,
280
+ f"{profile_topic} memory long-term recall",
281
+ f"{repo_topic} {profile_topic} integration",
282
+ ]
283
+
284
+ def github_headers() -> dict:
285
+ headers = {
286
+ "Accept": "application/vnd.github+json",
287
+ "User-Agent": "dream-runner",
288
+ }
289
+ if GITHUB_TOKEN:
290
+ headers["Authorization"] = f"Bearer {GITHUB_TOKEN}"
291
+ return headers
292
+
293
+ def git_extraheader() -> Optional[str]:
294
+ if not GITHUB_TOKEN:
295
+ return None
296
+ token = GITHUB_TOKEN.strip()
297
+ if not token:
298
+ return None
299
+ encoded = base64.b64encode(f"{GITHUB_GIT_USERNAME}:{token}".encode("utf-8")).decode("ascii")
300
+ return f"AUTHORIZATION: basic {encoded}"
301
+
302
+ def http_json(url: str, headers: Optional[dict] = None):
303
+ final_headers = {"User-Agent": "dream-runner"}
304
+ if headers:
305
+ final_headers.update(headers)
306
+ req = request.Request(url, headers=final_headers)
307
+ with request.urlopen(req, timeout=25) as resp:
308
+ return json.loads(resp.read().decode("utf-8"))
309
+
310
+ def github_json(api_path: str):
311
+ return http_json(f"{GITHUB_API_BASE}{api_path}", headers=github_headers())
312
+
313
+ def run_git_command(args: list[str], cwd: Optional[Path] = None):
314
+ command = ["git"]
315
+ extraheader = git_extraheader()
316
+ if extraheader:
317
+ command.extend(
318
+ [
319
+ "-c",
320
+ f"http.https://github.com/.extraheader={extraheader}",
321
+ ]
322
+ )
323
+ command.extend(args)
324
+ result = subprocess.run(
325
+ command,
326
+ cwd=str(cwd) if cwd else None,
327
+ capture_output=True,
328
+ text=True,
329
+ check=False,
330
+ )
331
+ if result.returncode != 0:
332
+ stderr = (result.stderr or "").strip()
333
+ raise RuntimeError(f"git {' '.join(args)} failed: {stderr or 'unknown error'}")
334
+ return (result.stdout or "").strip()
335
+
336
+ def sync_repo_snapshot_via_tarball(repo_path: Path):
337
+ if not (GITHUB_OWNER and GITHUB_REPO):
338
+ raise RuntimeError(
339
+ "DREAM_GITHUB_OWNER/DREAM_GITHUB_REPO are required for tarball sync fallback"
340
+ )
341
+ repo_path.parent.mkdir(parents=True, exist_ok=True)
342
+ api_url = f"{GITHUB_API_BASE}/repos/{GITHUB_OWNER}/{GITHUB_REPO}/tarball"
343
+ req = request.Request(api_url, headers=github_headers())
344
+ with request.urlopen(req, timeout=60) as resp:
345
+ payload = resp.read()
346
+
347
+ with tempfile.TemporaryDirectory(prefix="dream_repo_", dir=str(repo_path.parent)) as tmp_dir:
348
+ tmp_path = Path(tmp_dir)
349
+ archive_path = tmp_path / "repo.tar.gz"
350
+ archive_path.write_bytes(payload)
351
+
352
+ with tarfile.open(archive_path, "r:gz") as archive:
353
+ try:
354
+ archive.extractall(path=tmp_path, filter="data")
355
+ except TypeError:
356
+ archive.extractall(path=tmp_path)
357
+
358
+ extracted_dirs = [
359
+ path for path in tmp_path.iterdir() if path.is_dir() and path.name != "."
360
+ ]
361
+ if not extracted_dirs:
362
+ raise RuntimeError("tarball extraction produced no repository directory")
363
+
364
+ extracted_root = extracted_dirs[0]
365
+ if repo_path.exists():
366
+ shutil.rmtree(repo_path)
367
+ shutil.move(str(extracted_root), str(repo_path))
368
+
369
+ def ensure_local_repo_ready() -> Path:
370
+ if not REPO_LOCAL_PATH:
371
+ raise RuntimeError("DREAM_REPO_LOCAL_PATH is empty")
372
+ repo_path = Path(REPO_LOCAL_PATH)
373
+
374
+ if (repo_path / ".git").exists():
375
+ if REPO_REMOTE_URL:
376
+ try:
377
+ run_git_command(["fetch", "--depth", "1", "origin"], cwd=repo_path)
378
+ try:
379
+ run_git_command(["reset", "--hard", "origin/HEAD"], cwd=repo_path)
380
+ except Exception:
381
+ for candidate in ("origin/main", "origin/master"):
382
+ try:
383
+ run_git_command(["reset", "--hard", candidate], cwd=repo_path)
384
+ break
385
+ except Exception:
386
+ continue
387
+ except Exception:
388
+ pass
389
+ return repo_path
390
+
391
+ if repo_path.exists() and repo_path.is_dir():
392
+ return repo_path
393
+
394
+ if not REPO_REMOTE_URL:
395
+ raise RuntimeError("DREAM_REPO_REMOTE_URL is not configured and local repo is missing")
396
+
397
+ try:
398
+ repo_path.parent.mkdir(parents=True, exist_ok=True)
399
+ if repo_path.exists():
400
+ shutil.rmtree(repo_path)
401
+ run_git_command(["clone", "--depth", "1", REPO_REMOTE_URL, str(repo_path)])
402
+ except Exception:
403
+ sync_repo_snapshot_via_tarball(repo_path)
404
+ return repo_path
405
+
406
+ def collect_repo_full_scan_signals() -> list[str]:
407
+ if not REPO_SCAN_ENABLED:
408
+ return ["- repo scan disabled (DREAM_ENABLE_REPO_SCAN=false)"]
409
+ lines: list[str] = []
410
+ try:
411
+ repo_path = ensure_local_repo_ready()
412
+ except Exception as exc:
413
+ return [f"- repo scan unavailable: {normalize_line(str(exc), 180)}"]
414
+
415
+ total_markdown_files = 0
416
+ scored_items: list[tuple[int, str, int, str]] = []
417
+ keyword_hit_count = 0
418
+ keyword_map = {key: 0 for key in REPO_KEYWORDS}
419
+
420
+ for file_path in repo_path.rglob("*.md"):
421
+ if ".git" in file_path.parts:
422
+ continue
423
+ total_markdown_files += 1
424
+ try:
425
+ raw = file_path.read_text(encoding="utf-8", errors="ignore")
426
+ except Exception:
427
+ continue
428
+ lowered = raw.lower()
429
+ score = 0
430
+ for keyword in REPO_KEYWORDS:
431
+ hits = lowered.count(keyword)
432
+ if hits > 0:
433
+ keyword_map[keyword] += hits
434
+ score += hits
435
+ if score <= 0:
436
+ continue
437
+ keyword_hit_count += 1
438
+ rel_path = str(file_path.relative_to(repo_path))
439
+ snippet = normalize_line(raw.replace("\n", " "), 180)
440
+ scored_items.append((score, rel_path, len(raw), snippet))
441
+
442
+ scored_items.sort(key=lambda item: item[0], reverse=True)
443
+ lines.append(f"- repo_path={repo_path}")
444
+ lines.append(f"- scanned_markdown_files={total_markdown_files}")
445
+ lines.append(f"- keyword_hit_files={keyword_hit_count}")
446
+
447
+ hot_keywords = [
448
+ (keyword, count) for keyword, count in keyword_map.items() if count > 0
449
+ ]
450
+ hot_keywords.sort(key=lambda item: item[1], reverse=True)
451
+ if hot_keywords:
452
+ pairs = ", ".join(f"{k}:{v}" for k, v in hot_keywords[:10])
453
+ lines.append(f"- keyword_density={pairs}")
454
+
455
+ limit = max(10, min(400, REPO_FULL_SCAN_TOP_FILES))
456
+ for score, rel_path, char_count, snippet in scored_items[:limit]:
457
+ lines.append(f"- score={score} | {rel_path} | chars={char_count} | {snippet}")
458
+
459
+ if len(lines) <= 3:
460
+ lines.append("(full scan completed but no keyword matches found)")
461
+ return lines
462
+
463
+ def mcp_tool_call(name: str, arguments: dict):
464
+ payload = {
465
+ "jsonrpc": "2.0",
466
+ "id": 1,
467
+ "method": "tools/call",
468
+ "params": {
469
+ "name": name,
470
+ "arguments": arguments,
471
+ },
472
+ }
473
+ headers = {"content-type": "application/json"}
474
+ if MCP_BEARER_TOKEN:
475
+ headers["authorization"] = f"Bearer {MCP_BEARER_TOKEN}"
476
+ req = request.Request(
477
+ MCP_URL,
478
+ data=json.dumps(payload).encode("utf-8"),
479
+ headers=headers,
480
+ method="POST",
481
+ )
482
+ with request.urlopen(req, timeout=40) as resp:
483
+ envelope = json.loads(resp.read().decode("utf-8"))
484
+ if envelope.get("error"):
485
+ raise RuntimeError(f"MCP error: {envelope['error']}")
486
+ result = envelope.get("result") or {}
487
+ content = result.get("content") or []
488
+ text = content[0].get("text", "{}") if content else "{}"
489
+ try:
490
+ decoded = json.loads(text)
491
+ except json.JSONDecodeError:
492
+ decoded = {"raw": text}
493
+ if isinstance(decoded, dict) and decoded.get("ok") is False:
494
+ raise RuntimeError(f"Tool {name} failed: {decoded}")
495
+ return decoded
496
+
497
+ def collect_recent_memories() -> list[str]:
498
+ try:
499
+ result = mcp_tool_call(
500
+ "list_memories",
501
+ {
502
+ "limit": max(1, min(100, MEMORY_LIMIT)),
503
+ "unexpired_only": True,
504
+ },
505
+ )
506
+ except Exception as exc:
507
+ return [f"(memory fetch failed: {normalize_line(str(exc), 180)})"]
508
+
509
+ items = result.get("items") or []
510
+ lines = []
511
+ for item in items[: MEMORY_LIMIT]:
512
+ source = normalize_line(str(item.get("source") or "unknown"), 32)
513
+ body = normalize_line(str(item.get("body") or ""), 180)
514
+ created_at = normalize_line(str(item.get("created_at") or ""), 32)
515
+ if body:
516
+ lines.append(f"- [{source}] {body} ({created_at})")
517
+ return lines or ["(no recent memories)"]
518
+
519
+ def collect_semantic_memories() -> list[str]:
520
+ lines = []
521
+ seen_ids = set()
522
+ for query in parse_memory_queries():
523
+ try:
524
+ result = mcp_tool_call(
525
+ "search_memories",
526
+ {
527
+ "query": query,
528
+ "limit": max(1, min(100, SEMANTIC_MEMORY_LIMIT)),
529
+ "unexpired_only": True,
530
+ },
531
+ )
532
+ except Exception as exc:
533
+ lines.append(f"- query={query}: failed ({normalize_line(str(exc), 160)})")
534
+ continue
535
+
536
+ items = result.get("items") or []
537
+ picked = 0
538
+ for item in items:
539
+ memory_id = str(item.get("id") or "")
540
+ if memory_id and memory_id in seen_ids:
541
+ continue
542
+ if memory_id:
543
+ seen_ids.add(memory_id)
544
+ body = normalize_line(str(item.get("body") or ""), 170)
545
+ source = normalize_line(str(item.get("source") or "unknown"), 24)
546
+ similarity = item.get("similarity")
547
+ if body:
548
+ lines.append(f"- q={query} | [{source}] {body} (sim={similarity})")
549
+ picked += 1
550
+ if picked >= 4:
551
+ break
552
+ return lines or ["(semantic memories unavailable)"]
553
+
554
+ def collect_issue_snapshot() -> list[str]:
555
+ lines: list[str] = []
556
+ if not ISSUE_API:
557
+ return ["- issue snapshot skipped: DREAM_ISSUE_API not configured"]
558
+ try:
559
+ issue = http_json(ISSUE_API, headers=github_headers())
560
+ lines.append(
561
+ f"- #{issue.get('number')} {normalize_line(str(issue.get('title') or ''), 120)} | state={issue.get('state')} | updated={issue.get('updated_at')}"
562
+ )
563
+ issue_body = normalize_line(str(issue.get("body") or ""), 260)
564
+ if issue_body:
565
+ lines.append(f"- issue_body: {issue_body}")
566
+ except Exception as exc:
567
+ lines.append(f"- issue fetch failed: {normalize_line(str(exc), 180)}")
568
+ return lines
569
+
570
+ try:
571
+ comments = http_json(f"{ISSUE_API}/comments?per_page=2", headers=github_headers())
572
+ if isinstance(comments, list):
573
+ for idx, comment in enumerate(comments[-2:], start=1):
574
+ body = normalize_line(str(comment.get("body") or ""), 220)
575
+ updated = normalize_line(str(comment.get("updated_at") or ""), 32)
576
+ lines.append(f"- latest_comment_{idx}: {body} ({updated})")
577
+ except Exception as exc:
578
+ lines.append(f"- comments fetch failed: {normalize_line(str(exc), 180)}")
579
+
580
+ return lines
581
+
582
+ def collect_issue_feed() -> list[str]:
583
+ lines: list[str] = []
584
+ if not (GITHUB_OWNER and GITHUB_REPO):
585
+ return ["- issue feed skipped: DREAM_GITHUB_OWNER/DREAM_GITHUB_REPO not configured"]
586
+ try:
587
+ issues = github_json(
588
+ f"/repos/{GITHUB_OWNER}/{GITHUB_REPO}/issues?state=open&sort=updated&direction=desc&per_page={max(1,min(20,ISSUES_LIMIT))}"
589
+ )
590
+ if isinstance(issues, list):
591
+ for issue in issues:
592
+ if "pull_request" in issue:
593
+ continue
594
+ title = normalize_line(str(issue.get("title") or ""), 120)
595
+ number = issue.get("number")
596
+ updated = normalize_line(str(issue.get("updated_at") or ""), 32)
597
+ lines.append(f"- #{number} {title} (updated={updated})")
598
+ if len(lines) >= ISSUES_LIMIT:
599
+ break
600
+ except Exception as exc:
601
+ lines.append(f"- issues feed failed: {normalize_line(str(exc), 180)}")
602
+ return lines or ["(no issue feed)"]
603
+
604
+ def collect_soul_context() -> list[str]:
605
+ for path in SOUL_PATHS:
606
+ try:
607
+ if not path.exists():
608
+ continue
609
+ raw = path.read_text(encoding="utf-8", errors="ignore")
610
+ picked = []
611
+ for line in raw.splitlines():
612
+ stripped = line.strip()
613
+ if not stripped:
614
+ continue
615
+ if stripped.startswith("#"):
616
+ picked.append(stripped)
617
+ elif len(picked) < 10:
618
+ picked.append(normalize_line(stripped, 160))
619
+ if len(picked) >= 14:
620
+ break
621
+ if picked:
622
+ return [f"- source={path}"] + [f"- {line}" for line in picked]
623
+ except Exception as exc:
624
+ return [f"- soul read failed: {normalize_line(str(exc), 180)}"]
625
+ return ["- soul source not found"]
626
+
627
+ def build_digest(now_utc: datetime) -> str:
628
+ date_key = now_utc.strftime("%Y-%m-%d")
629
+ timestamp = now_utc.isoformat()
630
+ parts = [
631
+ f"# Dream Digest {date_key}",
632
+ f"generated_at: {timestamp}",
633
+ f"mode: {RUNNER_MODE}",
634
+ "",
635
+ ]
636
+
637
+ if ENABLE_RECENT_MEMORY:
638
+ parts.extend([
639
+ f"## Source: {DIGEST_BODY}-memory recent context",
640
+ *collect_recent_memories(),
641
+ "",
642
+ ])
643
+ else:
644
+ parts.extend([
645
+ "## Source: recent memory",
646
+ "- disabled by DREAM_ENABLE_RECENT_MEMORY=false",
647
+ "",
648
+ ])
649
+
650
+ if ENABLE_SEMANTIC_MEMORY:
651
+ parts.extend([
652
+ f"## Source: {DIGEST_BODY}-memory semantic context",
653
+ *collect_semantic_memories(),
654
+ "",
655
+ ])
656
+ else:
657
+ parts.extend([
658
+ "## Source: semantic memory",
659
+ "- disabled by DREAM_ENABLE_SEMANTIC_MEMORY=false",
660
+ "",
661
+ ])
662
+
663
+ if ENABLE_ISSUE_SIGNALS:
664
+ parts.extend([
665
+ f"## Source: {REPO_LABEL} key issue signals",
666
+ *collect_issue_snapshot(),
667
+ "",
668
+ f"## Source: {REPO_LABEL} issue feed",
669
+ *collect_issue_feed(),
670
+ "",
671
+ ])
672
+ else:
673
+ parts.extend([
674
+ "## Source: issue signals",
675
+ "- disabled by DREAM_ENABLE_ISSUE_SIGNALS=false",
676
+ "",
677
+ ])
678
+
679
+ if REPO_SCAN_ENABLED:
680
+ parts.extend([
681
+ f"## Source: {REPO_LABEL} repo full scan signals",
682
+ *collect_repo_full_scan_signals(),
683
+ "",
684
+ ])
685
+ else:
686
+ parts.extend([
687
+ "## Source: repo full scan",
688
+ "- disabled by DREAM_ENABLE_REPO_SCAN=false",
689
+ "",
690
+ ])
691
+
692
+ if ENABLE_SOUL_CONTEXT:
693
+ parts.extend([
694
+ f"## Source: {PROFILE_LABEL} soul baseline",
695
+ *collect_soul_context(),
696
+ "",
697
+ ])
698
+ else:
699
+ parts.extend([
700
+ "## Source: soul baseline",
701
+ "- disabled by DREAM_ENABLE_SOUL_CONTEXT=false",
702
+ "",
703
+ ])
704
+
705
+ parts.extend([
706
+ "## Dream synthesized notes",
707
+ "- Keep coco/toto profile isolation strict; no cross-body write path.",
708
+ "- Prioritize durable long-term memory chunks over transient tactical chatter.",
709
+ f"- Preserve provenance with origin={DIGEST_ORIGIN} for audit and recall filtering.",
710
+ ])
711
+
712
+ return "\n".join(parts).strip() + "\n"
713
+
714
+ def main() -> int:
715
+ global GITHUB_TOKEN
716
+ if not DREAM_ENABLED:
717
+ out = {
718
+ "ok": True,
719
+ "skipped": True,
720
+ "reason": "DREAM_ENABLED=false (or HERMES_ENABLED=false)",
721
+ "profile": PROFILE,
722
+ "mode": RUNNER_MODE,
723
+ }
724
+ print(json.dumps(out, ensure_ascii=False))
725
+ return 0
726
+
727
+ GITHUB_TOKEN = getenv_first(
728
+ ["DREAM_GITHUB_READ_TOKEN", "HERMES_GITHUB_READ_TOKEN"],
729
+ ""
730
+ ).strip()
731
+ now_utc = datetime.now(timezone.utc)
732
+ date_key = now_utc.strftime("%Y-%m-%d")
733
+ digest_text = build_digest(now_utc)
734
+ source_file = f"{DIGEST_SOURCE_DIR}/{date_key}.md"
735
+
736
+ ingest_result = mcp_tool_call(
737
+ "dream_ingest",
738
+ {
739
+ "content": digest_text,
740
+ "source_file": source_file,
741
+ "section": f"digest-{DIGEST_BODY}-{date_key}",
742
+ "tags": ["dream", "digest", "cron", DIGEST_BODY],
743
+ "type": "digest",
744
+ "date": date_key,
745
+ "body": DIGEST_BODY,
746
+ "visibility": "private",
747
+ "origin": DIGEST_ORIGIN,
748
+ "max_chunk_chars": max(300, min(3000, CHUNK_CHARS)),
749
+ },
750
+ )
751
+
752
+ out = {
753
+ "ok": True,
754
+ "generated_at": now_utc.isoformat(),
755
+ "mode": RUNNER_MODE,
756
+ "source_file": source_file,
757
+ "chunk_count": ingest_result.get("chunk_count"),
758
+ "inserted_count": ingest_result.get("inserted_count"),
759
+ "origin": ingest_result.get("origin", DIGEST_ORIGIN),
760
+ "providers": {
761
+ "recent_memory": ENABLE_RECENT_MEMORY,
762
+ "semantic_memory": ENABLE_SEMANTIC_MEMORY,
763
+ "issue_signals": ENABLE_ISSUE_SIGNALS,
764
+ "repo_scan": REPO_SCAN_ENABLED,
765
+ "soul_context": ENABLE_SOUL_CONTEXT
766
+ }
767
+ }
768
+ print(json.dumps(out, ensure_ascii=False))
769
+ return 0
770
+
771
+ if __name__ == "__main__":
772
+ try:
773
+ raise SystemExit(main())
774
+ except error.HTTPError as exc:
775
+ print(json.dumps({"ok": False, "error": f"HTTPError: {exc}"}, ensure_ascii=False))
776
+ raise
777
+ except Exception as exc:
778
+ print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
779
+ raise