@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.
- package/deploy/phase2/build_release_artifact.sh +56 -0
- package/deploy/phase3/smoke_gate.sh +223 -0
- package/deploy/systemd/memory-mcp-gateway@.service +26 -0
- package/package.json +41 -0
- package/scripts/dream_runner.py +779 -0
- package/scripts/hermes_digest_runner.py +36 -0
- package/scripts/tests/test_dream_runner_modes.py +90 -0
- package/server.mjs +4347 -0
|
@@ -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
|