mindsync-ai 1.1.0__py3-none-any.whl

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.
mindsync/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """MindSync AI: unified multi-agent memory, event bus, and agent dispatch over MCP."""
2
+
3
+ __version__ = "1.1.0"
mindsync/bridge.py ADDED
@@ -0,0 +1,516 @@
1
+ """SSH/SCP bridge to an optional remote durable store.
2
+
3
+ Remote layout is configurable. Defaults match the sample scripts under
4
+ ``examples/remote/``. No host, username, or server path is hard-coded.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import json
11
+ import re
12
+ import shlex
13
+ import subprocess
14
+ import time
15
+ import threading
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+ from mindsync.config import settings
20
+
21
+ # Safe identifier patterns (no shell metacharacters, path traversal, or colon)
22
+ _SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
23
+ _SAFE_SOURCE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
24
+ _SAFE_HEX = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
25
+
26
+ _FORBIDDEN_WINDOWS = {
27
+ "CON", "PRN", "AUX", "NUL",
28
+ "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
29
+ "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"
30
+ }
31
+
32
+ def validate_agent(val: str) -> str:
33
+ if not val or not _SAFE_ID.match(val):
34
+ raise ValueError(f"Invalid agent name {val!r}: must be alphanumeric + _ . - (max 128)")
35
+ return val
36
+
37
+ def _check_windows_safe(val: str) -> None:
38
+ upper = val.upper()
39
+ base = upper.split(".")[0]
40
+ if base in _FORBIDDEN_WINDOWS:
41
+ raise ValueError(f"Reserved Windows device name: {val}")
42
+ for char in ('<', '>', ':', '"', '/', '\\', '|', '?', '*'):
43
+ if char in val:
44
+ raise ValueError(f"Forbidden character {char!r} in {val}")
45
+
46
+ def validate_entity(val: str) -> str:
47
+ if not val or not _SAFE_ID.match(val):
48
+ raise ValueError(f"Invalid entity name {val!r}: must be alphanumeric + _ . - (max 128)")
49
+ _check_windows_safe(val)
50
+ return val
51
+
52
+ def validate_attribute(val: str) -> str:
53
+ if not val or not _SAFE_ID.match(val):
54
+ raise ValueError(f"Invalid attribute name {val!r}: must be alphanumeric + _ . - (max 128)")
55
+ _check_windows_safe(val)
56
+ return val
57
+
58
+ def validate_source(val: str) -> str:
59
+ if not val or not _SAFE_SOURCE.match(val):
60
+ raise ValueError(f"Invalid source {val!r}: must be alphanumeric + _ . : - (max 128)")
61
+ for char in ('<', '>', '"', '/', '\\', '|', '?', '*'):
62
+ if char in val:
63
+ raise ValueError(f"Forbidden character {char!r} in source {val}")
64
+ return val
65
+
66
+ def validate_fact_id(val: str) -> str:
67
+ if not val or not _SAFE_HEX.match(val):
68
+ raise ValueError(f"Invalid fact_id {val!r}")
69
+ return val
70
+
71
+ def validate_fact_text(val: str) -> str:
72
+ if not val:
73
+ raise ValueError("Fact text cannot be empty")
74
+ # Enforce limit on UTF-8 byte size
75
+ if len(val.encode("utf-8")) > 50 * 1024:
76
+ raise ValueError("Fact text exceeds 50KB limit")
77
+ return val
78
+
79
+
80
+ # SSH key filenames and the .ssh dir are redacted explicitly (not just as
81
+ # part of a full-path match) because they can show up as bare fragments in
82
+ # OSError/subprocess messages without a recognizable leading path prefix,
83
+ # e.g. "Permission denied (publickey) ... .ssh\id_ed25519".
84
+ _KEY_FILE_RE = re.compile(r"id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?", re.IGNORECASE)
85
+ _SSH_DIR_RE = re.compile(r"\.ssh\b")
86
+ # Full absolute path matches (greedy to end-of-token, not just the first two
87
+ # segments) so nothing after e.g. "/home/user" survives, on POSIX or Windows.
88
+ _HOME_TILDE_RE = re.compile(r"~[/\\][^\s'\"]*")
89
+ _WINDOWS_PATH_RE = re.compile(r"[A-Za-z]:\\[^\s'\"]+")
90
+ _POSIX_PATH_RE = re.compile(r"/[^\s'\"]+")
91
+
92
+
93
+ def _sanitize_error(err: str) -> str:
94
+ if not err:
95
+ return "unknown error"
96
+ if settings.ssh_host:
97
+ err = err.replace(settings.ssh_host, "[SSH_HOST]")
98
+ if settings.remote_root:
99
+ err = err.replace(settings.remote_root, "[REMOTE_ROOT]")
100
+ # Scrub SSH key/identity references first, since they can appear as
101
+ # bare fragments that a path-shaped regex wouldn't otherwise catch.
102
+ err = _KEY_FILE_RE.sub("[KEY_FILE]", err)
103
+ err = _SSH_DIR_RE.sub("[SSH_DIR]", err)
104
+ # Scrub full absolute paths and home-relative (~) paths.
105
+ err = _HOME_TILDE_RE.sub("[PATH]", err)
106
+ err = _WINDOWS_PATH_RE.sub("[PATH]", err)
107
+ err = _POSIX_PATH_RE.sub("[PATH]", err)
108
+ return err
109
+
110
+
111
+ _remote_cache: dict[str, Any] = {"status": "unknown", "reason": None, "checked_at": 0.0, "duration": 0.0}
112
+ _probe_lock = threading.Lock()
113
+
114
+ class BridgeError(Exception):
115
+ """Remote bridge failure."""
116
+
117
+
118
+ def _run(
119
+ args: list[str],
120
+ *,
121
+ timeout: float | None = None,
122
+ check: bool = True,
123
+ ) -> subprocess.CompletedProcess[str]:
124
+ return subprocess.run(
125
+ args,
126
+ capture_output=True,
127
+ text=True,
128
+ timeout=timeout,
129
+ check=check,
130
+ )
131
+
132
+
133
+ def remote_not_configured_error() -> str:
134
+ return (
135
+ "Remote sync is not configured. Set MINDSYNC_SSH_HOST and "
136
+ "MINDSYNC_REMOTE_ROOT (see .env.example)."
137
+ )
138
+
139
+ def get_remote_status(*, force: bool = False) -> dict[str, Any]:
140
+ if not settings.remote_enabled:
141
+ return {"status": "unknown", "reason": "remote_not_configured", "checked_at": 0.0, "duration": 0.0, "cache_age": 0.0}
142
+
143
+ now = time.time()
144
+ ttl = settings.remote_cache_ttl_seconds
145
+
146
+ with _probe_lock:
147
+ cache_age = now - float(_remote_cache["checked_at"])
148
+ if not force and _remote_cache["checked_at"] > 0 and cache_age < ttl:
149
+ res = dict(_remote_cache)
150
+ res["cache_age"] = cache_age
151
+ return res
152
+
153
+ start = time.time()
154
+ status = "offline"
155
+ reason = "unknown"
156
+ try:
157
+ res = _run(
158
+ [
159
+ "ssh",
160
+ "-o",
161
+ "BatchMode=yes",
162
+ "-o",
163
+ f"ConnectTimeout={settings.ssh_connect_timeout}",
164
+ settings.ssh_host,
165
+ "echo",
166
+ "1",
167
+ ],
168
+ timeout=settings.ssh_connect_timeout + 2,
169
+ check=False,
170
+ )
171
+ if res.returncode == 0:
172
+ status = "online"
173
+ reason = "ok"
174
+ elif res.returncode == 255:
175
+ status = "offline"
176
+ reason = "ssh_auth_or_timeout"
177
+ else:
178
+ status = "offline"
179
+ reason = f"exit_code_{res.returncode}"
180
+ except subprocess.TimeoutExpired:
181
+ status = "offline"
182
+ reason = "timeout"
183
+ except OSError:
184
+ status = "offline"
185
+ reason = "os_error"
186
+
187
+ duration = time.time() - start
188
+ _remote_cache.update({
189
+ "status": status,
190
+ "reason": reason,
191
+ "checked_at": time.time(),
192
+ "duration": round(duration, 3)
193
+ })
194
+
195
+ res = dict(_remote_cache)
196
+ res["cache_age"] = 0.0
197
+ return res
198
+
199
+ def check_remote_online(*, force: bool = False) -> bool:
200
+ """Cached SSH reachability probe. False when remote is disabled or unreachable."""
201
+ return get_remote_status(force=force)["status"] == "online"
202
+
203
+
204
+ # Backward-compatible alias used during the rename.
205
+ check_vps_online = check_remote_online
206
+
207
+
208
+ def _ssh_script(script: str, *, timeout: float = 60) -> subprocess.CompletedProcess[str]:
209
+ """Run a bash script on the remote host via stdin (avoids local shell quoting).
210
+
211
+ Always normalizes to LF bytes: Windows text mode would otherwise send CRLF
212
+ and break remote bash (`set -o pipefail\r`).
213
+ """
214
+ normalized = script.replace("\r\n", "\n").replace("\r", "\n").encode("utf-8")
215
+ res = subprocess.run(
216
+ [
217
+ "ssh",
218
+ "-o",
219
+ "BatchMode=yes",
220
+ "-o",
221
+ f"ConnectTimeout={settings.ssh_connect_timeout}",
222
+ settings.ssh_host,
223
+ "bash",
224
+ "-s",
225
+ ],
226
+ input=normalized,
227
+ capture_output=True,
228
+ timeout=timeout,
229
+ check=False,
230
+ )
231
+ return subprocess.CompletedProcess(
232
+ args=res.args,
233
+ returncode=res.returncode,
234
+ stdout=(res.stdout or b"").decode("utf-8", errors="replace"),
235
+ stderr=(res.stderr or b"").decode("utf-8", errors="replace"),
236
+ )
237
+
238
+
239
+ def _maybe_source_env() -> str:
240
+ """Optional remote env file source lines."""
241
+ env_file = settings.remote_env_file
242
+ if not env_file:
243
+ return ""
244
+ return f"""
245
+ if [ -f {shlex.quote(env_file)} ]; then
246
+ set -a
247
+ # shellcheck disable=SC1090
248
+ source {shlex.quote(env_file)}
249
+ set +a
250
+ fi
251
+ """
252
+
253
+
254
+ @dataclass
255
+ class WriteResult:
256
+ ok: bool
257
+ stdout: str = ""
258
+ stderr: str = ""
259
+ error: str | None = None
260
+ results: dict[str, Any] | None = None
261
+
262
+
263
+ def _looks_like_unsupported_batch_subcommand(result: "WriteResult") -> bool:
264
+ """Detect an old, write-only remote writer that predates the `batch`
265
+ subcommand (argparse rejects it with "invalid choice: 'batch'", exit 2).
266
+
267
+ This doubles as the capability probe: rather than spend an extra SSH
268
+ round trip proactively asking the remote what it supports, the first
269
+ real batch attempt IS the probe -- if it fails with this specific
270
+ signature we know the remote is old and fall back, otherwise the normal
271
+ (and far more common) path costs nothing extra.
272
+ """
273
+ text = f"{result.stderr or ''}\n{result.stdout or ''}".lower()
274
+ return "invalid choice" in text and "batch" in text
275
+
276
+
277
+ def _write_batch_native(valid_facts: list[dict[str, Any]]) -> WriteResult:
278
+ """Send the whole batch in one call via the `batch` subcommand (new remote)."""
279
+ payload = json.dumps(valid_facts)
280
+ b64 = base64.b64encode(payload.encode("utf-8")).decode("ascii")
281
+ remote_root = settings.remote_root
282
+ write_script = settings.remote_write_script
283
+ script = f"""set -euo pipefail
284
+ cd {shlex.quote(remote_root)}
285
+ {_maybe_source_env()}
286
+ PAYLOAD=$(printf '%s' {shlex.quote(b64)} | base64 -d)
287
+ python3 {shlex.quote(write_script)} batch --payload "$PAYLOAD"
288
+ """
289
+ try:
290
+ res = _ssh_script(script, timeout=90)
291
+ except subprocess.TimeoutExpired:
292
+ return WriteResult(ok=False, error="SSH write timed out")
293
+ except OSError as exc:
294
+ return WriteResult(ok=False, error=_sanitize_error(f"SSH write failed: {exc}"))
295
+
296
+ if res.returncode != 0:
297
+ err = (res.stderr or res.stdout or "unknown remote error").strip()
298
+ return WriteResult(ok=False, stdout=res.stdout, stderr=res.stderr, error=_sanitize_error(err))
299
+
300
+ parsed_results = None
301
+ try:
302
+ parsed_results = json.loads(res.stdout)
303
+ except json.JSONDecodeError:
304
+ pass
305
+
306
+ return WriteResult(ok=True, stdout=res.stdout.strip(), stderr=res.stderr, results=parsed_results)
307
+
308
+
309
+ def _write_single_via_legacy_protocol(fact: dict[str, Any]) -> WriteResult:
310
+ """Write one fact using the older single-write-only remote protocol."""
311
+ write_script = settings.remote_write_script
312
+ args = " ".join(
313
+ [
314
+ f"--fact_id {shlex.quote(fact['fact_id'])}",
315
+ f"--agent {shlex.quote(fact['agent'])}",
316
+ f"--entity {shlex.quote(fact['entity'])}",
317
+ f"--attribute {shlex.quote(fact['attribute'])}",
318
+ f"--text {shlex.quote(fact['text'])}",
319
+ f"--source {shlex.quote(fact['source'])}",
320
+ f"--confidence {shlex.quote(str(fact['confidence']))}",
321
+ ]
322
+ )
323
+ script = f"""set -euo pipefail
324
+ cd {shlex.quote(settings.remote_root)}
325
+ {_maybe_source_env()}
326
+ python3 {shlex.quote(write_script)} write {args}
327
+ """
328
+ try:
329
+ res = _ssh_script(script, timeout=30)
330
+ except subprocess.TimeoutExpired:
331
+ return WriteResult(ok=False, error="SSH write timed out")
332
+ except OSError as exc:
333
+ return WriteResult(ok=False, error=f"SSH write failed: {exc}")
334
+
335
+ if res.returncode != 0:
336
+ err = (res.stderr or res.stdout or "unknown remote error").strip()
337
+ return WriteResult(ok=False, stdout=res.stdout, stderr=res.stderr, error=err)
338
+ return WriteResult(ok=True, stdout=res.stdout.strip(), stderr=res.stderr)
339
+
340
+
341
+ def _write_batch_via_legacy_single_writes(valid_facts: list[dict[str, Any]]) -> WriteResult:
342
+ """Fallback for an already-deployed write-only remote: send facts one at
343
+ a time via the legacy `write` subcommand (one SSH round trip per fact).
344
+
345
+ Normalizes the result into the same {success_ids, failed} shape the
346
+ native `batch` path returns, so callers (sync_offline_facts) don't need
347
+ to know or care which protocol was actually used.
348
+ """
349
+ success_ids: list[str] = []
350
+ failed: list[dict[str, Any]] = []
351
+ for fact in valid_facts:
352
+ single = _write_single_via_legacy_protocol(fact)
353
+ if single.ok:
354
+ success_ids.append(fact["fact_id"])
355
+ else:
356
+ failed.append({"fact_id": fact["fact_id"], "error": single.error or "legacy write failed"})
357
+ return WriteResult(
358
+ ok=True,
359
+ stdout=f"Legacy single-write fallback: {len(success_ids)} ok, {len(failed)} failed.",
360
+ results={"success_ids": success_ids, "failed": failed},
361
+ )
362
+
363
+
364
+ def write_batch_remote(facts: list[dict[str, Any]]) -> WriteResult:
365
+ """Write a batch of durable facts on the remote host using base64 transport.
366
+
367
+ Backward compatible with an already-deployed remote writer that only
368
+ understands single writes (pre-`batch` protocol): if the native batch
369
+ call fails because the remote doesn't recognize the `batch` subcommand,
370
+ transparently falls back to one legacy `write` call per fact so a new
371
+ client keeps working against an old, un-upgraded remote.
372
+ """
373
+ if not settings.remote_enabled:
374
+ return WriteResult(ok=False, error=remote_not_configured_error())
375
+
376
+ if not facts:
377
+ return WriteResult(ok=True, stdout="No facts to write.")
378
+
379
+ # Validate all facts before sending
380
+ valid_facts = []
381
+ for fact in facts:
382
+ try:
383
+ agent = validate_agent(fact.get("agent", ""))
384
+ entity = validate_entity(fact.get("entity", ""))
385
+ attribute = validate_attribute(fact.get("attribute", ""))
386
+ source = validate_source(fact.get("source", ""))
387
+ fid = validate_fact_id(fact.get("fact_id", ""))
388
+ text = validate_fact_text(fact.get("text", ""))
389
+ conf = float(fact.get("confidence", 1.0))
390
+ if not 0.0 <= conf <= 1.0:
391
+ continue # Skip invalid
392
+ valid_facts.append({
393
+ "fact_id": fid,
394
+ "timestamp": fact.get("timestamp", ""),
395
+ "agent": agent,
396
+ "entity": entity,
397
+ "attribute": attribute,
398
+ "text": text,
399
+ "source": source,
400
+ "confidence": conf
401
+ })
402
+ except ValueError:
403
+ continue
404
+
405
+ if not valid_facts:
406
+ return WriteResult(ok=False, error="No valid facts to write in batch.")
407
+
408
+ result = _write_batch_native(valid_facts)
409
+ if not result.ok and _looks_like_unsupported_batch_subcommand(result):
410
+ return _write_batch_via_legacy_single_writes(valid_facts)
411
+ return result
412
+
413
+
414
+ def write_fact_remote(
415
+ *,
416
+ fact_id: str,
417
+ agent: str,
418
+ entity: str,
419
+ attribute: str,
420
+ text: str,
421
+ source: str,
422
+ confidence: float,
423
+ ) -> WriteResult:
424
+ """Write one durable fact on the remote host."""
425
+ validate_agent(agent)
426
+ validate_entity(entity)
427
+ validate_attribute(attribute)
428
+ validate_source(source)
429
+ validate_fact_id(fact_id)
430
+ validate_fact_text(text)
431
+ conf = float(confidence)
432
+ if not 0.0 <= conf <= 1.0:
433
+ raise ValueError("confidence must be between 0.0 and 1.0")
434
+
435
+ fact = {
436
+ "fact_id": fact_id,
437
+ "agent": agent,
438
+ "entity": entity,
439
+ "attribute": attribute,
440
+ "text": text,
441
+ "source": source,
442
+ "confidence": conf
443
+ }
444
+ return write_batch_remote([fact])
445
+
446
+
447
+ def consolidate_remote() -> WriteResult:
448
+ if not settings.remote_enabled:
449
+ return WriteResult(ok=False, error=remote_not_configured_error())
450
+
451
+ script = f"""set -euo pipefail
452
+ cd {shlex.quote(settings.remote_root)}
453
+ {_maybe_source_env()}
454
+ python3 {shlex.quote(settings.remote_consolidate_script)}
455
+ """
456
+ try:
457
+ res = _ssh_script(script, timeout=120)
458
+ except subprocess.TimeoutExpired:
459
+ return WriteResult(ok=False, error="SSH consolidate timed out")
460
+ except OSError as exc:
461
+ return WriteResult(ok=False, error=_sanitize_error(f"SSH consolidate failed: {exc}"))
462
+
463
+ if res.returncode != 0:
464
+ err = (res.stderr or res.stdout or "unknown remote error").strip()
465
+ return WriteResult(ok=False, stdout=res.stdout, stderr=res.stderr, error=_sanitize_error(err))
466
+ return WriteResult(ok=True, stdout=res.stdout.strip(), stderr=res.stderr)
467
+
468
+
469
+ def pull_compiled_truth() -> WriteResult:
470
+ """Pull remote compiled-truth directory without shell globs (Windows-safe)."""
471
+ if not settings.remote_enabled:
472
+ return WriteResult(ok=False, error=remote_not_configured_error())
473
+
474
+ settings.ensure_dirs()
475
+ staging_dir = settings.home / "staging-truth"
476
+ if staging_dir.exists():
477
+ import shutil
478
+ shutil.rmtree(staging_dir, ignore_errors=True)
479
+ staging_dir.mkdir(parents=True, exist_ok=True)
480
+
481
+ truth = settings.remote_truth_subdir.strip("/")
482
+ remote = f"{settings.ssh_host}:{settings.remote_root}/{truth}/."
483
+ dest = str(staging_dir)
484
+ try:
485
+ res = _run(
486
+ [
487
+ "scp",
488
+ "-o",
489
+ "BatchMode=yes",
490
+ "-o",
491
+ f"ConnectTimeout={settings.ssh_connect_timeout}",
492
+ "-r",
493
+ remote,
494
+ dest,
495
+ ],
496
+ timeout=120,
497
+ check=False,
498
+ )
499
+ except subprocess.TimeoutExpired:
500
+ return WriteResult(ok=False, error="SCP pull timed out")
501
+ except OSError as exc:
502
+ return WriteResult(ok=False, error=_sanitize_error(f"SCP pull failed: {exc}"))
503
+
504
+ if res.returncode != 0:
505
+ err = (res.stderr or res.stdout or "scp failed").strip()
506
+ return WriteResult(ok=False, stdout=res.stdout, stderr=res.stderr, error=_sanitize_error(err))
507
+
508
+ from mindsync.storage import publish_compiled_truth
509
+ try:
510
+ publish_compiled_truth(staging_dir)
511
+ except Exception as exc:
512
+ return WriteResult(ok=False, error=_sanitize_error(f"Failed to publish truth: {exc}"))
513
+
514
+ import shutil
515
+ shutil.rmtree(staging_dir, ignore_errors=True)
516
+ return WriteResult(ok=True)
@@ -0,0 +1,16 @@
1
+ """AgentRelay Event Bus module for MindSync."""
2
+
3
+ from mindsync.bus.models import Event, EventType, Identity, Message, PolicyRule
4
+ from mindsync.bus.events import EventBus, poll_events, publish_event, subscribe
5
+
6
+ __all__ = [
7
+ "Event",
8
+ "EventType",
9
+ "Identity",
10
+ "Message",
11
+ "PolicyRule",
12
+ "EventBus",
13
+ "publish_event",
14
+ "poll_events",
15
+ "subscribe",
16
+ ]