evolutiondb-notes-sync 0.1.0__tar.gz

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,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: evolutiondb-notes-sync
3
+ Version: 0.1.0
4
+ Summary: Sync macOS Apple Notes into EvolutionDB long-term memory.
5
+ Author-email: alptekin topal <topal.alptekin@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/alptekin/evolutiondb
8
+ Project-URL: Repository, https://github.com/alptekin/evolutiondb
9
+ Project-URL: Source, https://github.com/alptekin/evolutiondb/tree/main/client/notes-sync
10
+ Project-URL: Issues, https://github.com/alptekin/evolutiondb/issues
11
+ Keywords: evolutiondb,apple-notes,notes,macos,long-term-memory,agent-memory,mcp
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Database
24
+ Classifier: Topic :: Office/Business
25
+ Requires-Python: >=3.9
26
+ Requires-Dist: psycopg[binary]>=3.1
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: evolutiondb-notes-sync
3
+ Version: 0.1.0
4
+ Summary: Sync macOS Apple Notes into EvolutionDB long-term memory.
5
+ Author-email: alptekin topal <topal.alptekin@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/alptekin/evolutiondb
8
+ Project-URL: Repository, https://github.com/alptekin/evolutiondb
9
+ Project-URL: Source, https://github.com/alptekin/evolutiondb/tree/main/client/notes-sync
10
+ Project-URL: Issues, https://github.com/alptekin/evolutiondb/issues
11
+ Keywords: evolutiondb,apple-notes,notes,macos,long-term-memory,agent-memory,mcp
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Database
24
+ Classifier: Topic :: Office/Business
25
+ Requires-Python: >=3.9
26
+ Requires-Dist: psycopg[binary]>=3.1
@@ -0,0 +1,11 @@
1
+ pyproject.toml
2
+ evolutiondb_notes_sync.egg-info/PKG-INFO
3
+ evolutiondb_notes_sync.egg-info/SOURCES.txt
4
+ evolutiondb_notes_sync.egg-info/dependency_links.txt
5
+ evolutiondb_notes_sync.egg-info/entry_points.txt
6
+ evolutiondb_notes_sync.egg-info/requires.txt
7
+ evolutiondb_notes_sync.egg-info/top_level.txt
8
+ notes_sync/__init__.py
9
+ notes_sync/__main__.py
10
+ notes_sync/state.py
11
+ notes_sync/sync.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ evolutiondb-notes-sync = notes_sync.__main__:main
@@ -0,0 +1 @@
1
+ psycopg[binary]>=3.1
@@ -0,0 +1,8 @@
1
+ """evolutiondb-notes-sync — pull macOS Apple Notes content into
2
+ EvolutionDB long-term memory.
3
+
4
+ The first run will prompt for Automation permission so this binary
5
+ can talk to Notes.app over the JavaScript-for-Automation bridge.
6
+ That permission persists; subsequent runs are silent."""
7
+
8
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .sync import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,116 @@
1
+ """Memory backend — mirror of the sibling connector packages."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import sys
6
+ import time
7
+ from typing import Dict, Optional
8
+
9
+
10
+ def _e(s: str) -> str:
11
+ if not isinstance(s, str):
12
+ s = str(s)
13
+ s = s.replace("\r", " ").replace("\n", " ").replace("\t", " ")
14
+ return s.replace("'", "''")
15
+
16
+
17
+ WATERMARK_KEY = "notes_state_latest"
18
+
19
+
20
+ def _parse_value(raw):
21
+ if isinstance(raw, dict):
22
+ return raw
23
+ if not raw:
24
+ return {}
25
+ try:
26
+ return json.loads(raw)
27
+ except (TypeError, ValueError):
28
+ return {}
29
+
30
+
31
+ class MemoryStore:
32
+ _RECONNECT_ATTEMPTS = 3
33
+ _RECONNECT_BACKOFF_SEC = 0.5
34
+
35
+ def __init__(self, host: str, port: int, user: str, password: str,
36
+ database: str, store: str, namespace: str):
37
+ try:
38
+ import psycopg
39
+ except ImportError as exc:
40
+ raise RuntimeError(
41
+ "evolutiondb-notes-sync requires psycopg. Install "
42
+ "with `pip install psycopg[binary]>=3.1`.") from exc
43
+
44
+ self.psycopg = psycopg
45
+ self._conn_kwargs = dict(
46
+ host=host, port=port, user=user, password=password,
47
+ dbname=database, autocommit=True,
48
+ )
49
+ self.store = store
50
+ self.namespace = namespace
51
+ self.conn = self._connect()
52
+ try:
53
+ with self.conn.cursor() as cur:
54
+ cur.execute(f"CREATE MEMORY STORE {self.store}")
55
+ except Exception:
56
+ pass
57
+
58
+ def _connect(self):
59
+ return self.psycopg.connect(**self._conn_kwargs)
60
+
61
+ def _is_dead(self, exc: BaseException) -> bool:
62
+ return isinstance(exc, (self.psycopg.OperationalError,
63
+ self.psycopg.InterfaceError))
64
+
65
+ def _retry(self, fn):
66
+ last = None
67
+ for attempt in range(self._RECONNECT_ATTEMPTS):
68
+ try:
69
+ with self.conn.cursor() as cur:
70
+ return fn(cur)
71
+ except Exception as exc:
72
+ if not self._is_dead(exc):
73
+ raise
74
+ last = exc
75
+ print(f"[notes-sync] db connection lost "
76
+ f"(attempt {attempt + 1}): {exc}",
77
+ file=sys.stderr, flush=True)
78
+ try:
79
+ self.conn.close()
80
+ except Exception:
81
+ pass
82
+ if attempt + 1 < self._RECONNECT_ATTEMPTS:
83
+ time.sleep(self._RECONNECT_BACKOFF_SEC *
84
+ (attempt + 1))
85
+ try:
86
+ self.conn = self._connect()
87
+ except Exception as reconn:
88
+ last = reconn
89
+ continue
90
+ raise last # type: ignore[misc]
91
+
92
+ def get_watermark_iso(self) -> Optional[str]:
93
+ def run(cur):
94
+ cur.execute(
95
+ f"SELECT mem_value FROM __mem_{self.store} "
96
+ f"WHERE mem_namespace = '{_e(self.namespace)}' "
97
+ f"AND mem_key = '{_e(WATERMARK_KEY)}'")
98
+ rows = cur.fetchall()
99
+ if not rows:
100
+ return None
101
+ v = _parse_value(rows[0][0]).get("modified_at")
102
+ return str(v) if v else None
103
+ return self._retry(run)
104
+
105
+ def set_watermark_iso(self, iso: str) -> None:
106
+ value = json.dumps({"modified_at": iso,
107
+ "saved_at": time.time()})
108
+ self._retry(lambda cur: cur.execute(
109
+ f"MEMORY PUT INTO {self.store} VALUES "
110
+ f"('{_e(self.namespace)}','{_e(WATERMARK_KEY)}','{_e(value)}')"))
111
+
112
+ def put_record(self, key: str, record: Dict) -> None:
113
+ value = json.dumps(record, ensure_ascii=False)
114
+ self._retry(lambda cur: cur.execute(
115
+ f"MEMORY PUT INTO {self.store} VALUES "
116
+ f"('{_e(self.namespace)}','{_e(key)}','{_e(value)}')"))
@@ -0,0 +1,283 @@
1
+ """
2
+ evolutiondb-notes-sync — talk to Notes.app over JavaScript-for-
3
+ Automation, then store the captured notes in EvolutionDB long-term
4
+ memory.
5
+
6
+ Why JXA, not the SQLite store?
7
+ ------------------------------
8
+ Apple Notes encodes the body of each note as gzip + a private
9
+ NotesBody protobuf inside ZICCLOUDSYNCINGOBJECT.ZDATA in
10
+ NoteStore.sqlite. Reverse-engineering that format is a moving
11
+ target across macOS versions. JXA gives us the rendered HTML body
12
+ straight from Notes.app and never breaks across releases.
13
+
14
+ Trade-off: the user has to allow Automation in System Settings →
15
+ Privacy & Security → Automation → Terminal/iTerm → Notes. macOS
16
+ prompts for this on first invocation.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import html
22
+ import json
23
+ import os
24
+ import re
25
+ import signal
26
+ import subprocess
27
+ import sys
28
+ import time
29
+ from pathlib import Path
30
+ from typing import Dict, Iterable, List, Optional
31
+
32
+ from . import state as state_mod
33
+
34
+
35
+ # JXA evaluated by osascript. Emits one JSON object per line so a
36
+ # Python iterator can process notes without holding everything in
37
+ # memory if there are tens of thousands.
38
+ _JXA = r"""
39
+ ObjC.import('Foundation');
40
+ const Notes = Application('Notes');
41
+ Notes.includeStandardAdditions = false;
42
+
43
+ function isoDate(d) {
44
+ if (!d) return '';
45
+ const f = $.NSISO8601DateFormatter.alloc.init;
46
+ f.formatOptions = $.NSISO8601DateFormatWithInternetDateTime;
47
+ return ObjC.unwrap(f.stringFromDate(d));
48
+ }
49
+
50
+ function* iterNotes() {
51
+ const ns = Notes.notes();
52
+ for (let i = 0; i < ns.length; i++) {
53
+ try {
54
+ const n = ns[i];
55
+ const folder = (() => {
56
+ try {
57
+ return n.container().name();
58
+ } catch (e) { return ''; }
59
+ })();
60
+ yield {
61
+ id: n.id(),
62
+ name: n.name(),
63
+ body: n.body(),
64
+ folder: folder,
65
+ modified: isoDate(n.modificationDate()),
66
+ created: isoDate(n.creationDate()),
67
+ };
68
+ } catch (e) {
69
+ yield {error: String(e)};
70
+ }
71
+ }
72
+ }
73
+
74
+ for (const n of iterNotes()) {
75
+ console.log(JSON.stringify(n));
76
+ }
77
+ """
78
+
79
+
80
+ def _html_to_text(body_html: str) -> str:
81
+ """Cheap HTML→text. Apple Notes body uses simple tags
82
+ (h1/p/div/br/li). We strip tags, decode entities, collapse
83
+ whitespace, normalise line breaks."""
84
+ if not body_html:
85
+ return ""
86
+ s = body_html
87
+ s = re.sub(r"</?(?:br|p|div|h[1-6]|li)[^>]*>", "\n", s,
88
+ flags=re.I)
89
+ s = re.sub(r"<[^>]+>", " ", s)
90
+ s = html.unescape(s)
91
+ s = re.sub(r"[ \t]+", " ", s)
92
+ s = re.sub(r"\n[ \t]+", "\n", s)
93
+ s = re.sub(r"\n{3,}", "\n\n", s)
94
+ return s.strip()
95
+
96
+
97
+ def iter_notes() -> Iterable[Dict]:
98
+ """Spawn osascript and stream JSON notes line by line."""
99
+ try:
100
+ proc = subprocess.Popen(
101
+ ["/usr/bin/osascript", "-l", "JavaScript", "-e", _JXA],
102
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
103
+ text=True, bufsize=1)
104
+ except FileNotFoundError:
105
+ print("[notes-sync] /usr/bin/osascript not found — Notes "
106
+ "sync is macOS-only.", file=sys.stderr, flush=True)
107
+ return
108
+
109
+ assert proc.stdout is not None
110
+ try:
111
+ for line in proc.stdout:
112
+ line = line.strip()
113
+ if not line:
114
+ continue
115
+ try:
116
+ obj = json.loads(line)
117
+ except json.JSONDecodeError:
118
+ continue
119
+ if "error" in obj:
120
+ print(f"[notes-sync] note skipped: {obj['error']}",
121
+ file=sys.stderr, flush=True)
122
+ continue
123
+ yield obj
124
+ finally:
125
+ try:
126
+ proc.stdout.close()
127
+ except Exception:
128
+ pass
129
+ rc = proc.wait(timeout=600)
130
+ if rc != 0 and proc.stderr is not None:
131
+ err = proc.stderr.read().strip()
132
+ if err:
133
+ print(f"[notes-sync] osascript stderr: {err[:400]}",
134
+ file=sys.stderr, flush=True)
135
+
136
+
137
+ # ---------------------------------------------------------------- #
138
+ def _load_dotenv(path: Path) -> None:
139
+ if not path.exists():
140
+ return
141
+ for raw in path.read_text(encoding="utf-8").splitlines():
142
+ line = raw.strip()
143
+ if not line or line.startswith("#") or "=" not in line:
144
+ continue
145
+ k, _, v = line.partition("=")
146
+ k, v = k.strip(), v.strip().strip('"').strip("'")
147
+ if v:
148
+ os.environ.setdefault(k, v)
149
+
150
+
151
+ class Config:
152
+ def __init__(self):
153
+ self.evosql_host = os.environ.get("EVOSQL_HOST", "127.0.0.1")
154
+ self.evosql_port = int(os.environ.get("EVOSQL_PORT", "5433"))
155
+ self.evosql_user = os.environ.get("EVOSQL_USER", "admin")
156
+ self.evosql_pass = os.environ.get("EVOSQL_PASSWORD", "admin")
157
+ self.evosql_db = os.environ.get("EVOSQL_DATABASE", "evosql")
158
+ self.user_id = os.environ.get("MCP_USER_ID",
159
+ "default_user")
160
+ self.store = os.environ.get("NOTES_MEMORY_STORE",
161
+ "mcp_mem")
162
+
163
+
164
+ def _build_record(n: Dict) -> Optional[Dict]:
165
+ title = (n.get("name") or "").strip() or "(untitled)"
166
+ text = _html_to_text(n.get("body") or "")
167
+ if not text and title == "(untitled)":
168
+ return None
169
+ modified = n.get("modified") or n.get("created") or ""
170
+ return {
171
+ "fact": f"Note \"{title}\": {text[:240]}",
172
+ "source": "notes",
173
+ "kind": "note",
174
+ "title": title,
175
+ "text": text,
176
+ "folder": n.get("folder") or "",
177
+ "note_id": n.get("id") or "",
178
+ "modified_at": modified,
179
+ "created_at": n.get("created") or "",
180
+ "tags": ["notes", "apple-notes"],
181
+ }
182
+
183
+
184
+ def _note_key(n: Dict) -> str:
185
+ raw_id = (n.get("note_id") or "").replace("/", "_")
186
+ return f"note_{raw_id}"
187
+
188
+
189
+ def sync_once(cfg: Config, *, since_iso: Optional[str],
190
+ dry_run: bool = False) -> Dict[str, int]:
191
+ counters = {"notes": 0, "skipped": 0, "errors": 0}
192
+ store: Optional[state_mod.MemoryStore] = None
193
+ if not dry_run:
194
+ store = state_mod.MemoryStore(
195
+ host=cfg.evosql_host, port=cfg.evosql_port,
196
+ user=cfg.evosql_user, password=cfg.evosql_pass,
197
+ database=cfg.evosql_db, store=cfg.store,
198
+ namespace=cfg.user_id,
199
+ )
200
+
201
+ wm = (store.get_watermark_iso() if store else None)
202
+ floor = wm or since_iso
203
+ highest = floor or ""
204
+
205
+ try:
206
+ for raw in iter_notes():
207
+ rec = _build_record(raw)
208
+ if not rec:
209
+ counters["skipped"] += 1
210
+ continue
211
+ if floor and rec["modified_at"] <= floor:
212
+ continue
213
+ if store:
214
+ store.put_record(_note_key(rec), rec)
215
+ counters["notes"] += 1
216
+ if rec["modified_at"] > highest:
217
+ highest = rec["modified_at"]
218
+ except Exception as exc: # noqa: BLE001
219
+ print(f"[notes-sync] read failed: {exc}",
220
+ file=sys.stderr, flush=True)
221
+ counters["errors"] += 1
222
+
223
+ if store and highest and highest != (wm or ""):
224
+ store.set_watermark_iso(highest)
225
+ return counters
226
+
227
+
228
+ _stop = False
229
+
230
+
231
+ def _install_signal_handlers() -> None:
232
+ def _handler(_signum, _frame):
233
+ global _stop
234
+ _stop = True
235
+ print("[notes-sync] stop requested",
236
+ file=sys.stderr, flush=True)
237
+ for s in (signal.SIGTERM, signal.SIGINT):
238
+ try:
239
+ signal.signal(s, _handler)
240
+ except (ValueError, OSError):
241
+ pass
242
+
243
+
244
+ def main(argv: Optional[List[str]] = None) -> int:
245
+ parser = argparse.ArgumentParser(prog="evolutiondb-notes-sync",
246
+ description="Sync macOS Apple Notes into EvolutionDB.")
247
+ parser.add_argument("--once", action="store_true")
248
+ parser.add_argument("--interval", type=int)
249
+ parser.add_argument("--since",
250
+ default="",
251
+ help="Optional ISO floor for first run (e.g. 2026-01-01T00:00:00Z). "
252
+ "Empty means import everything.")
253
+ parser.add_argument("--dry-run", action="store_true")
254
+ parser.add_argument("--env-file", default=".env")
255
+ args = parser.parse_args(argv)
256
+
257
+ _load_dotenv(Path(args.env_file).expanduser())
258
+ cfg = Config()
259
+ _install_signal_handlers()
260
+
261
+ def run_pass() -> int:
262
+ try:
263
+ counts = sync_once(cfg, since_iso=args.since or None,
264
+ dry_run=args.dry_run)
265
+ print(json.dumps({"ok": True, **counts}), flush=True)
266
+ return 0
267
+ except Exception as exc: # noqa: BLE001
268
+ print(json.dumps({"ok": False, "error": str(exc)}),
269
+ flush=True)
270
+ return 4
271
+
272
+ if args.once or not args.interval:
273
+ return run_pass()
274
+
275
+ interval = max(60, int(args.interval))
276
+ rc = 0
277
+ while not _stop:
278
+ rc = run_pass()
279
+ for _ in range(interval):
280
+ if _stop:
281
+ break
282
+ time.sleep(1)
283
+ return rc
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "evolutiondb-notes-sync"
7
+ version = "0.1.0"
8
+ description = "Sync macOS Apple Notes into EvolutionDB long-term memory."
9
+ requires-python = ">=3.9"
10
+ license = {text = "MIT"}
11
+ keywords = ["evolutiondb", "apple-notes", "notes", "macos",
12
+ "long-term-memory", "agent-memory", "mcp"]
13
+ authors = [{name = "alptekin topal", email = "topal.alptekin@gmail.com"}]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: MacOS",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Database",
27
+ "Topic :: Office/Business",
28
+ ]
29
+ dependencies = ["psycopg[binary]>=3.1"]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/alptekin/evolutiondb"
33
+ Repository = "https://github.com/alptekin/evolutiondb"
34
+ Source = "https://github.com/alptekin/evolutiondb/tree/main/client/notes-sync"
35
+ Issues = "https://github.com/alptekin/evolutiondb/issues"
36
+
37
+ [project.scripts]
38
+ evolutiondb-notes-sync = "notes_sync.__main__:main"
39
+
40
+ [tool.setuptools.packages.find]
41
+ include = ["notes_sync*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+