@bcmyguest/openmem 0.0.0-stage → 0.6.3

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.
Files changed (191) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +136 -2
  3. package/bin/hindsight-project-index +530 -0
  4. package/cli/coding-agent/access-policy.mjs +38 -0
  5. package/cli/coding-agent/agents.mjs +34 -0
  6. package/cli/coding-agent/bank-from-cwd.mjs +140 -0
  7. package/cli/coding-agent/capabilities.mjs +23 -0
  8. package/cli/coding-agent/hook-harnesses.mjs +87 -0
  9. package/cli/coding-agent/hook-runner.mjs +260 -0
  10. package/cli/coding-agent/install-access.mjs +93 -0
  11. package/cli/coding-agent/install-shim.mjs +97 -0
  12. package/cli/coding-agent/opencode-config.mjs +91 -0
  13. package/cli/coding-agent/opencode-plugin.mjs +96 -0
  14. package/cli/coding-agent/runtime.mjs +39 -0
  15. package/cli/coding-agent/session-facade.mjs +62 -0
  16. package/cli/cognee-mcp.mjs +260 -0
  17. package/cli/feed-sync.mjs +11 -0
  18. package/cli/install-coding-agent.mjs +165 -0
  19. package/cli/install-memory-mcp.mjs +426 -0
  20. package/cli/is-main.mjs +18 -0
  21. package/cli/mcp/context.mjs +61 -0
  22. package/cli/mcp/export/cognee-dataset-name.mjs +12 -0
  23. package/cli/mcp/export/cognee.mjs +119 -0
  24. package/cli/mcp/export/hindsight.mjs +48 -0
  25. package/cli/mcp/export/index.mjs +26 -0
  26. package/cli/mcp/export/mem0.mjs +31 -0
  27. package/cli/mcp/llm-synthesize.mjs +50 -0
  28. package/cli/mcp/mem0-scope-id.mjs +22 -0
  29. package/cli/mcp/memory-facade.mjs +455 -0
  30. package/cli/mcp/memory-reflect-consolidate.mjs +120 -0
  31. package/cli/mcp/notebooklm-banks.mjs +21 -0
  32. package/cli/mcp/notebooklm-hindsight-rest.mjs +45 -0
  33. package/cli/mcp/notebooklm-provider.mjs +225 -0
  34. package/cli/mcp/pmm-records.mjs +23 -0
  35. package/cli/mcp/portability.mjs +158 -0
  36. package/cli/mcp/stdio.mjs +65 -0
  37. package/cli/mcp/unified-mcp-install.mjs +65 -0
  38. package/cli/mem0-mcp.mjs +108 -0
  39. package/cli/notebooklm-mcp.mjs +236 -0
  40. package/cli/openmem-mcp.mjs +35 -0
  41. package/cli/openmem.mjs +205 -0
  42. package/cli/roots.mjs +85 -0
  43. package/cli/stack.mjs +436 -0
  44. package/cli/switch-memory-provider.mjs +358 -0
  45. package/cli/wiring-report.mjs +125 -0
  46. package/compose.bench.yaml +12 -0
  47. package/compose.yaml +164 -0
  48. package/config/coding-agent.json +13 -0
  49. package/config/hindsight.env.example +24 -0
  50. package/config/index.yml +54 -0
  51. package/config/litellm.lemond.yaml +40 -0
  52. package/config/litellm.ollama.yaml +33 -0
  53. package/config/notebooklm.yml +33 -0
  54. package/config/openmem.toml.example +62 -0
  55. package/docs/OPENMEM.md +23 -0
  56. package/docs/architecture.md +219 -0
  57. package/docs/assets/README.md +10 -0
  58. package/docs/assets/setup-demo.gif +0 -0
  59. package/docs/coding-agent-hooks.md +51 -0
  60. package/docs/coding-agent-scopes.md +22 -0
  61. package/docs/coding-agents.md +51 -0
  62. package/docs/cognee.md +37 -0
  63. package/docs/concepts.md +112 -0
  64. package/docs/hindsight.md +74 -0
  65. package/docs/index-sources-roadmap.md +38 -0
  66. package/docs/index-sources.md +39 -0
  67. package/docs/indexer-sinks.md +122 -0
  68. package/docs/indexing.md +57 -0
  69. package/docs/manual-stack-setup.md +74 -0
  70. package/docs/mcp-install-guide.md +4 -0
  71. package/docs/mcp-setup.md +101 -0
  72. package/docs/mem0.md +103 -0
  73. package/docs/memory-portability.md +73 -0
  74. package/docs/memory-providers.md +246 -0
  75. package/docs/model-configuration.md +84 -0
  76. package/docs/notebooklm.md +83 -0
  77. package/docs/npx-install.md +170 -0
  78. package/docs/openmem-config.md +194 -0
  79. package/docs/provider-wizard-hooks.md +222 -0
  80. package/docs/quickstart.md +101 -0
  81. package/docs/setup-wizard.md +116 -0
  82. package/docs/sources-feeds.md +52 -0
  83. package/docs/sources-readwise.md +23 -0
  84. package/docs/sources-zotero.md +22 -0
  85. package/glama.json +4 -0
  86. package/indexer/Dockerfile +38 -0
  87. package/indexer/code_outline.py +146 -0
  88. package/indexer/feed_sync.py +107 -0
  89. package/indexer/index_config.py +290 -0
  90. package/indexer/pytest.ini +5 -0
  91. package/indexer/sinks/__init__.py +148 -0
  92. package/indexer/sinks/_http.py +44 -0
  93. package/indexer/sinks/cognee.py +329 -0
  94. package/indexer/sinks/hindsight.py +107 -0
  95. package/indexer/sinks/mem0.py +247 -0
  96. package/indexer/watch.py +240 -0
  97. package/package.json +95 -4
  98. package/providers/_template/compose.yaml +52 -0
  99. package/providers/cognee/OPERATOR.md +82 -0
  100. package/providers/cognee/compose.yaml +89 -0
  101. package/providers/cognee/lib/clack.mjs +157 -0
  102. package/providers/cognee/lib/coding-agent.mjs +26 -0
  103. package/providers/cognee/lib/cognee-mcp-install.mjs +153 -0
  104. package/providers/cognee/lib/config.mjs +98 -0
  105. package/providers/cognee/lib/env-file.mjs +194 -0
  106. package/providers/cognee/lib/env-setup.mjs +83 -0
  107. package/providers/cognee/lib/llm-env.mjs +382 -0
  108. package/providers/cognee/lib/ports.mjs +25 -0
  109. package/providers/cognee/lib/smoke.mjs +246 -0
  110. package/providers/cognee/lib/stack-env.mjs +134 -0
  111. package/providers/cognee/lib/state.mjs +101 -0
  112. package/providers/cognee/lib/summary.mjs +56 -0
  113. package/providers/cognee/lib/validate.mjs +97 -0
  114. package/providers/cognee/manifest.json +56 -0
  115. package/providers/cognee/wizard.mjs +134 -0
  116. package/providers/graphiti/manifest.json +15 -0
  117. package/providers/hindsight/lib/clack.mjs +245 -0
  118. package/providers/hindsight/lib/coding-agent.mjs +204 -0
  119. package/providers/hindsight/lib/config.mjs +73 -0
  120. package/providers/hindsight/lib/env-setup.mjs +71 -0
  121. package/providers/hindsight/lib/litellm-routes.mjs +187 -0
  122. package/providers/hindsight/lib/model-backend.mjs +214 -0
  123. package/providers/hindsight/lib/notebooklm-mcp-install.mjs +127 -0
  124. package/providers/hindsight/lib/ollama-compose.mjs +51 -0
  125. package/providers/hindsight/lib/ports.mjs +30 -0
  126. package/providers/hindsight/lib/probe.mjs +33 -0
  127. package/providers/hindsight/lib/stack-env.mjs +132 -0
  128. package/providers/hindsight/lib/state.mjs +75 -0
  129. package/providers/hindsight/lib/summary.mjs +43 -0
  130. package/providers/hindsight/lib/validate.mjs +75 -0
  131. package/providers/hindsight/manifest.json +58 -0
  132. package/providers/hindsight/wizard.mjs +149 -0
  133. package/providers/letta/manifest.json +29 -0
  134. package/providers/mem0/OPERATOR.md +124 -0
  135. package/providers/mem0/compose.yaml +57 -0
  136. package/providers/mem0/lib/coding-agent.mjs +58 -0
  137. package/providers/mem0/lib/config.mjs +45 -0
  138. package/providers/mem0/lib/configure.mjs +135 -0
  139. package/providers/mem0/lib/dotenv.mjs +71 -0
  140. package/providers/mem0/lib/env-file.mjs +110 -0
  141. package/providers/mem0/lib/http.mjs +69 -0
  142. package/providers/mem0/lib/mcp-install.mjs +165 -0
  143. package/providers/mem0/lib/secret.mjs +30 -0
  144. package/providers/mem0/lib/smoke.mjs +179 -0
  145. package/providers/mem0/lib/stack-env.mjs +216 -0
  146. package/providers/mem0/lib/state.mjs +67 -0
  147. package/providers/mem0/lib/summary.mjs +60 -0
  148. package/providers/mem0/lib/url.mjs +106 -0
  149. package/providers/mem0/lib/validate.mjs +158 -0
  150. package/providers/mem0/manifest.json +47 -0
  151. package/providers/mem0/wizard.mjs +133 -0
  152. package/providers/zep/manifest.json +29 -0
  153. package/scripts/check-pack.mjs +218 -0
  154. package/scripts/gen-masks.mjs +83 -0
  155. package/scripts/gen-masks.sh +4 -0
  156. package/scripts/promotion-provider-smoke.mjs +34 -0
  157. package/scripts/run-setup.sh +30 -0
  158. package/scripts/setup.mjs +94 -0
  159. package/scripts/setup.sh +5 -0
  160. package/scripts/spin-up.sh +45 -0
  161. package/scripts/wizard.sh +5 -0
  162. package/server.json +34 -0
  163. package/skills/openmem/SKILL.md +29 -0
  164. package/sync/Dockerfile +27 -0
  165. package/sync/memory_backend.py +87 -0
  166. package/sync/notebooklm_sync.py +664 -0
  167. package/wizard/bin/pm.mjs +97 -0
  168. package/wizard/bin/setup.mjs +33 -0
  169. package/wizard/lib/compose-version.mjs +32 -0
  170. package/wizard/lib/compose.mjs +70 -0
  171. package/wizard/lib/curl.mjs +103 -0
  172. package/wizard/lib/docker-build.mjs +148 -0
  173. package/wizard/lib/exec.mjs +41 -0
  174. package/wizard/lib/indexer.mjs +6 -0
  175. package/wizard/lib/load-machine-env.mjs +4 -0
  176. package/wizard/lib/log.mjs +32 -0
  177. package/wizard/lib/notebooklm-agent-access.mjs +92 -0
  178. package/wizard/lib/notebooklm.mjs +214 -0
  179. package/wizard/lib/parse-args.mjs +87 -0
  180. package/wizard/lib/paths.mjs +39 -0
  181. package/wizard/lib/pm-config.mjs +163 -0
  182. package/wizard/lib/ports.mjs +94 -0
  183. package/wizard/lib/prompts.mjs +57 -0
  184. package/wizard/lib/provider-wizard/helpers.mjs +98 -0
  185. package/wizard/lib/provider-wizard/run-steps.mjs +129 -0
  186. package/wizard/lib/providers/registry.mjs +262 -0
  187. package/wizard/lib/run-clack.mjs +108 -0
  188. package/wizard/lib/run-wizard.mjs +755 -0
  189. package/wizard/lib/validate-setup.mjs +62 -0
  190. package/wizard/lib/wizard-action.mjs +34 -0
  191. package/wizard/lib/wizard-conf.mjs +71 -0
@@ -0,0 +1,247 @@
1
+ """mem0 sink: the self-hosted mem0 REST server (`server/main.py` in github.com/mem0ai/mem0), stdlib only.
2
+
3
+ Namespace mapping. `bank` is the mem0 `user_id` and `doc_id` is the mem0 `run_id`, both through `scope_id`. mem0
4
+ trims an entity id and then rejects one that still contains whitespace (`_validate_and_trim_entity_id` in
5
+ `mem0/memory/main.py`; it has no length or charset rule), so `scope_id` is the identity for any id without
6
+ whitespace, and otherwise replaces each run of whitespace with `_` and appends `~` plus the first 8 hex digits of the
7
+ SHA-256 of the original id (`obsidian::My Vault` -> `obsidian::My_Vault~<8 hex>`). The suffix keeps two different
8
+ ids that differ only in whitespace apart, and the mapping is deterministic, so the same bank and document always
9
+ resolve to the same ids. It is applied to every use of the two ids (the lookup, the writes, the deletes). The
10
+ original text of a mapped id is not stored. Every memory also carries the fixed `agent_id`
11
+ `openmem-indexer`, for provenance only: lookups filter on `user_id` and `run_id` and never on `agent_id`.
12
+ mem0 has no bank to create (the `user_id` exists once a memory carries it), so `prepare` is a no-op.
13
+
14
+ Documents. mem0 gives every memory a server-generated id and has no upsert, so the sink resolves a document to its
15
+ memories with `GET /memories?user_id=<bank>&run_id=<doc_id>&top_k=1000` (percent-encoded). `retain` is
16
+ write-then-delete so that a failure never loses a document: it lists the document's current memory ids, writes the
17
+ new text, and only then deletes the listed ids (`DELETE /memories/{id}`; a 404 for an already-gone id is swallowed).
18
+ A failure part-way leaves the old memories, possibly next to some of the new ones; the indexer keeps the document
19
+ marked as changed, and its next run repeats the whole sequence, which converges on exactly the new chunks. Until
20
+ then a search can see duplicates. The text is split into chunks of at most 3000 characters (cut at a blank line,
21
+ else a newline, else a space, else mid-text; the chunks concatenate back to the original text, only chunks that are
22
+ pure whitespace are dropped), and each chunk is one `POST /memories` with `infer` literally false, so mem0 stores it
23
+ verbatim instead of asking an LLM to rewrite it. Each reply must be a JSON object whose `results` list is not empty
24
+ (infer=false answers one ADD result per message), otherwise the retain fails. A document of empty or whitespace-only
25
+ text has no chunks: its old memories are deleted and nothing is written (the document is simply absent, which is
26
+ what an empty document means to a search).
27
+
28
+ Limits and races. A document that would need more than 900 chunks (about 2.7 million characters) is rejected before
29
+ any request is sent, with a message naming the document id, so the 1000-entry listing can never hide a stale chunk
30
+ and a rejected retain leaves the previous version intact. The indexer retries a failed document on every run, so such
31
+ a document fails its whole source on every run until it is shortened or excluded. mem0 enforces no uniqueness on
32
+ `run_id`: two indexer runs that retain the same document concurrently (for example with different state files) can
33
+ both list the old memories and both write, leaving two sets of chunks. The next retain of that document lists and
34
+ removes all of them.
35
+
36
+ Metadata. The caller's metadata is copied; a key that mem0 reserves, drops or overwrites in the stored payload
37
+ (`data`, `user_id`, `agent_id`, `run_id`, `actor_id`, `hash`, `created_at`, `updated_at`, `expiration_date`, `role`,
38
+ `text_lemmatized`, `attributed_to`) is renamed with the prefix `meta_`. The sink then adds `tags` (a list, only when
39
+ there are tags), `context` and `timestamp` (only when not None; mem0 has no event-time field) and, for a document of
40
+ several chunks, `chunk` (0-based index) and `chunks` (the total). These sink-owned keys win over a caller key of
41
+ the same name.
42
+
43
+ Auth. `MEM0_API_KEY` is read from the environment inside `create`, never from a document or a path, and is sent as
44
+ `X-API-Key` on every request except the health check (`/docs` is open, so no key travels with it). Without a key no
45
+ auth header is sent. The key never appears in an error message. Over plain `http://` the key travels in cleartext:
46
+ use `https://` (or a loopback address) for any server that is not on the same host. The sink follows no redirects
47
+ (see `sinks/_http.py`; the mem0 server has `redirect_slashes=False` and API calls never redirect), so a 3xx is an
48
+ error carrying its status, and the key cannot be handed to another host.
49
+
50
+ URL. There is no default: mem0 publishes no standard port. `create` reads `MEMORY_API_URL` when no url is passed and
51
+ raises `MissingUrlError` (a `SinkError` and a `ValueError`, so the CLI reports it as a sink error) when both are empty,
52
+ when the URL has no http(s) scheme or host, or when it embeds credentials (`user:pass@host`; use `MEM0_API_KEY`).
53
+ Error messages never echo the URL given. Health is `GET /docs`, which the server leaves open (it has no dedicated
54
+ health route).
55
+ """
56
+
57
+ import hashlib
58
+ import json
59
+ import os
60
+ import urllib.error
61
+ import urllib.parse
62
+ import urllib.request
63
+ from collections.abc import Mapping
64
+
65
+ from sinks import Capabilities, SinkError
66
+ from sinks._http import open_url, read_all
67
+
68
+ URL_ENV = "MEMORY_API_URL"
69
+ KEY_ENV = "MEM0_API_KEY"
70
+ AGENT_ID = "openmem-indexer"
71
+ CHUNK_CHARS = 3000
72
+ MAX_CHUNKS = 900
73
+ LIST_LIMIT = 1000
74
+ RESERVED_KEYS = frozenset(
75
+ {"data", "user_id", "agent_id", "run_id", "actor_id", "hash", "created_at", "updated_at", "expiration_date", "role", "text_lemmatized", "attributed_to"}
76
+ )
77
+ READ_TIMEOUT = 30
78
+ WRITE_TIMEOUT = 300
79
+ HEALTH_TIMEOUT = 5
80
+
81
+
82
+ class MissingUrlError(SinkError, ValueError):
83
+ """No usable API URL. A ValueError too, so `bin/hindsight-project-index` reports it as a sink error (exit 2)."""
84
+
85
+
86
+ class HttpError(SinkError):
87
+ def __init__(self, method: str, url: str, status: int, body: str):
88
+ super().__init__(f"{method} {url}: HTTP {status}: {body}", status)
89
+
90
+
91
+ def scope_id(value: str) -> str:
92
+ """Map a bank or document id to a mem0 entity id: itself when it has no whitespace, else a whitespace-free form.
93
+
94
+ Each run of whitespace becomes `_`, then `~` and the first 8 hex digits of sha256(value) are appended.
95
+ """
96
+ if not any(char.isspace() for char in value):
97
+ return value
98
+ pieces = []
99
+ in_space = False
100
+ for char in value:
101
+ if char.isspace():
102
+ if not in_space:
103
+ pieces.append("_")
104
+ in_space = True
105
+ else:
106
+ pieces.append(char)
107
+ in_space = False
108
+ return "".join(pieces) + "~" + hashlib.sha256(value.encode()).hexdigest()[:8]
109
+
110
+
111
+ def split_chunks(text: str, limit: int = CHUNK_CHARS) -> list[str]:
112
+ """Cut `text` into pieces of at most `limit` characters whose concatenation is `text`, minus pure-whitespace pieces."""
113
+ pieces = []
114
+ position = 0
115
+ while len(text) - position > limit:
116
+ window = text[position : position + limit]
117
+ cut = limit
118
+ for separator in ("\n\n", "\n", " "):
119
+ found = window.rfind(separator)
120
+ if found >= 0:
121
+ cut = found + len(separator)
122
+ break
123
+ pieces.append(text[position : position + cut])
124
+ position += cut
125
+ pieces.append(text[position:])
126
+ return [piece for piece in pieces if piece.strip()]
127
+
128
+
129
+ class Mem0Sink:
130
+ capabilities = Capabilities(async_ingest=False)
131
+
132
+ def __init__(self, url: str, api_key: str | None = None):
133
+ self.url = url.rstrip("/")
134
+ self._api_key = api_key or None
135
+
136
+ def _headers(self, with_body: bool) -> dict[str, str]:
137
+ headers = {"Content-Type": "application/json"} if with_body else {}
138
+ if self._api_key:
139
+ headers["X-API-Key"] = self._api_key
140
+ return headers
141
+
142
+ def _excerpt(self, body: bytes) -> str:
143
+ text = body.decode(errors="replace")[:300]
144
+ return text.replace(self._api_key, "***") if self._api_key else text
145
+
146
+ def _request(self, method: str, path: str, payload: dict | None = None, timeout: float = READ_TIMEOUT) -> object:
147
+ """One request; returns the decoded JSON reply, or None for an empty or non-JSON body."""
148
+ url = self.url + path
149
+ data = json.dumps(payload).encode() if payload is not None else None
150
+ try:
151
+ with open_url(urllib.request.Request(url, data=data, headers=self._headers(data is not None), method=method), timeout) as response:
152
+ body = read_all(response)
153
+ except urllib.error.HTTPError as error:
154
+ try:
155
+ excerpt = self._excerpt(read_all(error))
156
+ except OSError:
157
+ excerpt = ""
158
+ raise HttpError(method, url, error.code, excerpt) from error
159
+ try:
160
+ return json.loads(body) if body else None
161
+ except ValueError:
162
+ return None
163
+
164
+ def _memory_ids(self, user_id: str, run_id: str) -> list[str]:
165
+ query = urllib.parse.urlencode({"user_id": user_id, "run_id": run_id, "top_k": LIST_LIMIT}, quote_via=urllib.parse.quote)
166
+ reply = self._request("GET", "/memories?" + query)
167
+ results = reply.get("results") if isinstance(reply, dict) else None
168
+ if not isinstance(results, list) or not all(isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"] for item in results):
169
+ raise SinkError(f"GET {self.url}/memories: unexpected reply (want an object with a `results` list of memories that have an id); cannot tell which memories the document has")
170
+ return [item["id"] for item in results]
171
+
172
+ def _delete_ids(self, memory_ids: list[str]) -> None:
173
+ for memory_id in memory_ids:
174
+ try:
175
+ self._request("DELETE", "/memories/" + urllib.parse.quote(memory_id, safe=""))
176
+ except HttpError as error:
177
+ # 404: already gone (deleted server-side or by a concurrent run).
178
+ if error.status != 404:
179
+ raise
180
+
181
+ def health(self) -> bool:
182
+ try:
183
+ with open_url(urllib.request.Request(self.url + "/docs"), HEALTH_TIMEOUT) as response:
184
+ return 200 <= response.status < 300
185
+ except (OSError, ValueError): # includes HTTPError, URLError and, via open_url, malformed HTTP replies
186
+ return False
187
+
188
+ def prepare(self, bank: str) -> None:
189
+ return None
190
+
191
+ def retain(
192
+ self,
193
+ bank: str,
194
+ doc_id: str,
195
+ content: str,
196
+ *,
197
+ tags: list[str] | None = None,
198
+ metadata: Mapping[str, str] | None = None,
199
+ context: str | None = None,
200
+ timestamp: str | None = None,
201
+ ) -> None:
202
+ chunks = split_chunks(content)
203
+ if len(chunks) > MAX_CHUNKS:
204
+ raise SinkError(f"document {doc_id!r} needs {len(chunks)} chunks of at most {CHUNK_CHARS} characters; the mem0 sink accepts at most {MAX_CHUNKS} per document")
205
+ user_id, run_id = scope_id(bank), scope_id(doc_id)
206
+ base = {f"meta_{key}" if key in RESERVED_KEYS else key: value for key, value in (metadata or {}).items()}
207
+ if tags:
208
+ base["tags"] = list(tags)
209
+ if context is not None:
210
+ base["context"] = context
211
+ if timestamp is not None:
212
+ base["timestamp"] = timestamp
213
+ old_ids = self._memory_ids(user_id, run_id)
214
+ for index, chunk in enumerate(chunks):
215
+ meta = dict(base)
216
+ if len(chunks) > 1:
217
+ meta["chunk"] = index
218
+ meta["chunks"] = len(chunks)
219
+ body = {"messages": [{"role": "user", "content": chunk}], "user_id": user_id, "agent_id": AGENT_ID, "run_id": run_id, "infer": False, "metadata": meta}
220
+ reply = self._request("POST", "/memories", body, WRITE_TIMEOUT)
221
+ results = reply.get("results") if isinstance(reply, dict) else None
222
+ if not isinstance(results, list) or not results:
223
+ raise SinkError(f"POST {self.url}/memories: mem0 stored nothing for chunk {index + 1} of {len(chunks)} of document {doc_id!r} (want an object with a non-empty `results` list)")
224
+ self._delete_ids(old_ids)
225
+
226
+ def delete(self, bank: str, doc_id: str) -> None:
227
+ self._delete_ids(self._memory_ids(scope_id(bank), scope_id(doc_id)))
228
+
229
+
230
+ def create(url: str | None = None, **_cfg) -> Mem0Sink:
231
+ resolved = (url or os.environ.get(URL_ENV) or "").strip()
232
+ if not resolved:
233
+ raise MissingUrlError(f"mem0 sink needs an API URL: set {URL_ENV} or pass --url")
234
+ # No message below echoes the URL: it could carry a password.
235
+ if not resolved.lower().startswith(("http://", "https://")):
236
+ raise MissingUrlError("mem0 sink API URL must start with http:// or https://")
237
+ try:
238
+ parts = urllib.parse.urlsplit(resolved)
239
+ host = parts.hostname
240
+ _ = parts.port # raises ValueError for a malformed port
241
+ except ValueError:
242
+ raise MissingUrlError("mem0 sink API URL is not a valid URL") from None
243
+ if "@" in parts.netloc:
244
+ raise MissingUrlError(f"mem0 sink API URL must not contain credentials (user:pass@host); set {KEY_ENV} for the API key")
245
+ if not host:
246
+ raise MissingUrlError("mem0 sink API URL has no host")
247
+ return Mem0Sink(resolved, (os.environ.get(KEY_ENV) or "").strip() or None)
@@ -0,0 +1,240 @@
1
+ #!/usr/bin/env python3
2
+ """Keep Hindsight current: watch Obsidian vaults with inotify, poll git repositories, re-list sources periodically.
3
+
4
+ inotify is not used for repositories: they hold hundreds of thousands of directories, which exceeds the host's
5
+ max_user_watches limit. Repositories are polled by fingerprint (HEAD plus tracked-file status) instead.
6
+ """
7
+
8
+ import hashlib
9
+ import os
10
+ import subprocess
11
+ import sys
12
+ import threading
13
+ import time
14
+ from collections.abc import Mapping
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ import index_config
19
+
20
+
21
+ INDEXER = "hindsight-project-index"
22
+ MAX_BACKOFF_SECONDS = 600
23
+ IGNORED_DIRS = {"node_modules", "build", "dist", "target", "vendor", "__pycache__"}
24
+ DRY_RUN = os.environ.get("INDEXER_DRY_RUN") == "1"
25
+
26
+
27
+ def timings(settings: index_config.Config, environ: Mapping[str, str]) -> tuple[int, int, int]:
28
+ """Return (poll, vault quiet, relist) seconds. Environment variables override index.yml."""
29
+ return (
30
+ int(environ.get("POLL_SECONDS", settings.poll_seconds)),
31
+ int(environ.get("VAULT_QUIET_SECONDS", settings.vault_quiet_seconds)),
32
+ int(environ.get("RELIST_SECONDS", settings.relist_seconds)),
33
+ )
34
+
35
+
36
+ # Loaded at import, so an invalid config stops the watcher before it starts.
37
+ POLL_SECONDS, VAULT_QUIET_SECONDS, LIST_SECONDS = timings(index_config.load(), os.environ)
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class RepoWatch:
42
+ indexed: str | None # fingerprint at the last successful index
43
+ seen: str | None # fingerprint at the previous poll
44
+
45
+
46
+ def log(message: str) -> None:
47
+ print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {message}", flush=True)
48
+
49
+
50
+ def git_fingerprint(repo: Path) -> str:
51
+ """HEAD plus a digest of tracked-file changes. Untracked files are ignored, matching the indexer."""
52
+ head = subprocess.run(["git", "rev-parse", "--verify", "-q", "HEAD"], cwd=repo, capture_output=True, text=True, check=False)
53
+ head_id = head.stdout.strip() if head.returncode == 0 else "unborn"
54
+ status = subprocess.run(["git", "status", "--porcelain=v1", "-uno"], cwd=repo, capture_output=True, check=True).stdout
55
+ return f"{head_id}:{hashlib.sha256(status).hexdigest()}"
56
+
57
+
58
+ def step(state: RepoWatch, fingerprint: str) -> tuple[RepoWatch, bool]:
59
+ """Advance one poll. Return the new state and whether to index now.
60
+
61
+ A change indexes only after the fingerprint stays unchanged for one more poll.
62
+ """
63
+ new_state = RepoWatch(state.indexed, fingerprint)
64
+ if fingerprint == state.indexed:
65
+ return new_state, False
66
+ return new_state, fingerprint == state.seen
67
+
68
+
69
+ def mark_indexed(state: RepoWatch, fingerprint: str) -> RepoWatch:
70
+ return RepoWatch(fingerprint, state.seen)
71
+
72
+
73
+ def list_sources() -> dict[Path, str]:
74
+ result = subprocess.run([INDEXER, "--list"], capture_output=True, text=True, check=True)
75
+ sources: dict[Path, str] = {}
76
+ for line in result.stdout.splitlines():
77
+ kind, _, path = line.partition("\t")
78
+ if kind and path:
79
+ sources[Path(path)] = kind
80
+ return sources
81
+
82
+
83
+ def root_for(path: Path, roots: list[Path]) -> Path | None:
84
+ matches = [root for root in roots if path == root or root in path.parents]
85
+ return max(matches, key=lambda root: len(root.parts), default=None)
86
+
87
+
88
+ def make_filter(roots: list[Path]):
89
+ def keep(_change, raw_path: str) -> bool:
90
+ path = Path(raw_path)
91
+ root = root_for(path, roots)
92
+ if root is None:
93
+ return False
94
+ # Dot-prefixed paths cover .git, .obsidian, .trash, .venv, and dotfile caches.
95
+ return not any(part.startswith(".") or part in IGNORED_DIRS for part in path.relative_to(root).parts)
96
+
97
+ return keep
98
+
99
+
100
+ def run_indexer(kind: str, path: Path) -> int:
101
+ flag = {
102
+ "repo": "--repo",
103
+ "vault": "--vault",
104
+ "notes": "--notes",
105
+ "papers": "--papers",
106
+ "bookmarks": "--bookmarks",
107
+ "feed": "--feed",
108
+ }[kind]
109
+ command = [INDEXER, "--apply", flag, str(path)]
110
+ log(f"index {kind} {path}")
111
+ if DRY_RUN:
112
+ log(f"dry run, not executed: {' '.join(command)}")
113
+ return 0
114
+ try:
115
+ code = subprocess.run(command, check=False).returncode
116
+ except OSError as error:
117
+ log(f"index {kind} {path} could not start: {error}")
118
+ return 1
119
+ log(f"index {kind} {path} exited {code}")
120
+ return code
121
+
122
+
123
+ def watch_vaults(vaults: list[Path], stop: threading.Event) -> None:
124
+ """Index each vault after VAULT_QUIET_SECONDS without changes. Returns when stop is set."""
125
+ from watchfiles import watch # imported here so the pure helpers work without watchfiles
126
+
127
+ pending: dict[Path, float] = {}
128
+ for changes in watch(*vaults, watch_filter=make_filter(vaults), rust_timeout=1000, yield_on_timeout=True, stop_event=stop):
129
+ for _change, raw_path in changes:
130
+ root = root_for(Path(raw_path), vaults)
131
+ if root is not None:
132
+ pending[root] = time.monotonic()
133
+ for root, last in list(pending.items()):
134
+ if time.monotonic() - last >= VAULT_QUIET_SECONDS:
135
+ del pending[root]
136
+ run_indexer("vault", root)
137
+ # Changes made during the run arrive in later batches and re-trigger the vault.
138
+
139
+
140
+ class VaultWatcher:
141
+ def __init__(self, vaults: list[Path]):
142
+ self.vaults = vaults
143
+ self.stop = threading.Event()
144
+ self.thread = threading.Thread(target=self._run, name="vault-watch", daemon=True)
145
+
146
+ def start(self) -> "VaultWatcher":
147
+ self.thread.start()
148
+ return self
149
+
150
+ def close(self) -> None:
151
+ # Does not join: a running index may take minutes, and the poller must keep going.
152
+ self.stop.set()
153
+
154
+ def _run(self) -> None:
155
+ backoff = 1
156
+ while not self.stop.is_set():
157
+ try:
158
+ watch_vaults(self.vaults, self.stop)
159
+ return
160
+ except Exception as error:
161
+ log(f"vault watcher failed: {type(error).__name__}: {error}; retrying in {backoff}s")
162
+ self.stop.wait(backoff)
163
+ backoff = min(backoff * 2, MAX_BACKOFF_SECONDS)
164
+
165
+
166
+ def index_repo(path: Path, repos: dict[Path, RepoWatch]) -> None:
167
+ try:
168
+ baseline: str | None = git_fingerprint(path)
169
+ except Exception as error:
170
+ log(f"{path}: fingerprint failed: {type(error).__name__}: {error}")
171
+ baseline = None
172
+ indexed = baseline if run_indexer("repo", path) == 0 else None
173
+ repos[path] = RepoWatch(indexed, baseline)
174
+
175
+
176
+ def poll_repos(repos: dict[Path, RepoWatch]) -> None:
177
+ for path, state in list(repos.items()):
178
+ try:
179
+ fingerprint = git_fingerprint(path)
180
+ except Exception as error:
181
+ log(f"{path}: fingerprint failed: {type(error).__name__}: {error}")
182
+ continue
183
+ repos[path], should_index = step(state, fingerprint)
184
+ if should_index and run_indexer("repo", path) == 0:
185
+ repos[path] = mark_indexed(repos[path], fingerprint)
186
+
187
+
188
+ def refresh(kinds: dict[Path, str], repos: dict[Path, RepoWatch], watcher: VaultWatcher | None) -> tuple[dict[Path, str], VaultWatcher | None]:
189
+ try:
190
+ found = list_sources()
191
+ except Exception as error:
192
+ log(f"listing sources failed, keeping {len(kinds)} known: {type(error).__name__}: {error}")
193
+ return kinds, watcher
194
+ if not found:
195
+ log("no sources selected; nothing to index or watch")
196
+ for path in set(kinds) - set(found):
197
+ log(f"source removed: {path}")
198
+ repos.pop(path, None)
199
+ watch_kinds = ("vault", "notes", "papers", "bookmarks", "feed")
200
+ watched = sorted(path for path, kind in found.items() if kind in watch_kinds)
201
+ if watched != sorted(path for path, kind in kinds.items() if kind in watch_kinds):
202
+ # Restart the watcher before indexing new vaults, so edits made meanwhile are queued, not lost.
203
+ if watcher is not None:
204
+ watcher.close()
205
+ watcher = VaultWatcher(watched).start() if watched else None
206
+ for path, kind in found.items():
207
+ if path in kinds:
208
+ continue
209
+ log(f"new {kind} source: {path}")
210
+ if kind in watch_kinds:
211
+ run_indexer(kind, path)
212
+ else:
213
+ index_repo(path, repos)
214
+ return found, watcher
215
+
216
+
217
+ def main() -> int:
218
+ log(f"poll {POLL_SECONDS}s, vault quiet {VAULT_QUIET_SECONDS}s, source listing {LIST_SECONDS}s" + (", dry run" if DRY_RUN else ""))
219
+ kinds: dict[Path, str] = {}
220
+ repos: dict[Path, RepoWatch] = {}
221
+ watcher: VaultWatcher | None = None
222
+ next_list = 0.0
223
+ failures = 0
224
+ while True:
225
+ try:
226
+ if time.monotonic() >= next_list:
227
+ next_list = time.monotonic() + LIST_SECONDS
228
+ kinds, watcher = refresh(kinds, repos, watcher)
229
+ poll_repos(repos)
230
+ failures = 0
231
+ time.sleep(POLL_SECONDS)
232
+ except Exception as error:
233
+ failures += 1
234
+ delay = min(POLL_SECONDS * 2**failures, MAX_BACKOFF_SECONDS)
235
+ log(f"iteration failed: {type(error).__name__}: {error}; retrying in {delay}s")
236
+ time.sleep(delay)
237
+
238
+
239
+ if __name__ == "__main__":
240
+ sys.exit(main())
package/package.json CHANGED
@@ -1,6 +1,97 @@
1
1
  {
2
2
  "name": "@bcmyguest/openmem",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.6.3",
4
+ "description": "OpenMem \u2014 memory interface for coding agents: pluggable providers (Hindsight, mem0, Cognee, \u2026), shared wizard/indexer, multi-format import/export (PMM and more).",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "author": "B Guest",
8
+ "homepage": "https://bcmyguest.github.io/openmem/",
9
+ "bugs": {
10
+ "url": "https://github.com/bcmyguest/openmem/issues"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/bcmyguest/openmem.git"
15
+ },
16
+ "mcpName": "io.github.bcmyguest/openmem",
17
+ "keywords": [
18
+ "openmem",
19
+ "memory",
20
+ "ai-memory",
21
+ "hindsight",
22
+ "mem0",
23
+ "cognee",
24
+ "mcp",
25
+ "mcp-server",
26
+ "docker-compose",
27
+ "setup-wizard",
28
+ "coding-agent",
29
+ "pmm"
30
+ ],
31
+ "bin": {
32
+ "openmem": "cli/openmem.mjs",
33
+ "openmem-setup": "wizard/bin/setup.mjs",
34
+ "openmem-mcp": "cli/openmem-mcp.mjs",
35
+ "openmem-install-mcp": "cli/install-memory-mcp.mjs",
36
+ "openmem-install-coding-agent": "cli/install-coding-agent.mjs",
37
+ "openmem-switch-provider": "cli/switch-memory-provider.mjs",
38
+ "openmem-notebooklm-mcp": "cli/notebooklm-mcp.mjs",
39
+ "openmem-cognee-mcp": "cli/cognee-mcp.mjs"
40
+ },
41
+ "files": [
42
+ "cli/",
43
+ "skills/",
44
+ "wizard/bin/",
45
+ "wizard/lib/",
46
+ "providers/",
47
+ "compose.yaml",
48
+ "compose.bench.yaml",
49
+ "indexer/",
50
+ "bin/",
51
+ "sync/",
52
+ "config/*.example",
53
+ "config/index.yml",
54
+ "config/notebooklm.yml",
55
+ "config/litellm.*.yaml",
56
+ "config/coding-agent.json",
57
+ "scripts/*.mjs",
58
+ "scripts/*.sh",
59
+ "README.md",
60
+ "LICENSE",
61
+ "glama.json",
62
+ "server.json",
63
+ "docs/",
64
+ "!docs/ROADMAP*",
65
+ "!docs/WAVE*",
66
+ "!docs/ANNOUNCEMENT*",
67
+ "!docs/PUBLISH*",
68
+ "!**/__pycache__",
69
+ "!**/*.pyc",
70
+ "!**/tests",
71
+ "!**/.pytest_cache"
72
+ ],
73
+ "engines": {
74
+ "node": ">=20.12.0"
75
+ },
76
+ "publishConfig": {
77
+ "access": "public"
78
+ },
79
+ "scripts": {
80
+ "setup": "node wizard/bin/setup.mjs",
81
+ "pm": "node wizard/bin/pm.mjs",
82
+ "openmem": "node cli/openmem.mjs",
83
+ "install-mcp": "node cli/install-memory-mcp.mjs",
84
+ "install-coding-agent": "node cli/install-coding-agent.mjs",
85
+ "feed-sync": "node cli/feed-sync.mjs",
86
+ "switch-provider": "node cli/switch-memory-provider.mjs",
87
+ "test": "for t in scripts/tests/test_*.mjs; do node \"$t\" || exit 1; done && bash scripts/tests/test_wizard.sh",
88
+ "test:indexer": "cd indexer && python3 -m pytest -q",
89
+ "test:unit": "node scripts/tests/test_cli_package.mjs --unit",
90
+ "pack:check": "node scripts/check-pack.mjs"
91
+ },
92
+ "dependencies": {
93
+ "@clack/prompts": "^1.2.0",
94
+ "@iarna/toml": "^2.2.5",
95
+ "yaml": "^2.9.1"
96
+ }
97
+ }
@@ -0,0 +1,52 @@
1
+ # TEMPLATE compose fragment for a memory provider. Copy to providers/<id>/compose.yaml and replace every
2
+ # "example" / EXAMPLE_ below with the provider id. Directories starting with "_" are not providers; the wizard's
3
+ # provider registry must skip them.
4
+ #
5
+ # Layering (the wizard passes both, then `up -d`):
6
+ # docker compose -f compose.yaml -f providers/<id>/compose.yaml --profile <id> up -d
7
+ # Add `-f compose.override.yaml` when you use the generated secret masks (Compose skips that file once -f is given).
8
+ #
9
+ # What the base compose.yaml gives you:
10
+ # - `indexer` and `indexer-state-init` have no profile and no provider dependency, so they start for every
11
+ # provider. This fragment points the indexer at your service and makes it wait for it.
12
+ # - `litellm` and `hindsight` carry the profile "hindsight" and do not start under any other profile.
13
+ # Keep this fragment small: one service (or a few), its volumes, and the indexer wiring. Never copy the indexer
14
+ # build or image; `openmem-indexer:local` is shared.
15
+
16
+ services:
17
+ example:
18
+ profiles: ["example"]
19
+ # Pin a tag or digest. Bump deliberately.
20
+ image: example/memory-server:0.0.0
21
+ # Loopback only. Host port from .env (EXAMPLE_PORT); change the default to the provider's usual port + 10000.
22
+ ports:
23
+ - "127.0.0.1:${EXAMPLE_PORT:-18080}:8080"
24
+ # Secrets live in an env file outside the repo, like HINDSIGHT_ENV_FILE. Delete if the provider needs none.
25
+ # required: false lets `config` and `up` run before the wizard has written the file (Compose 2.24+).
26
+ env_file:
27
+ - path: ${EXAMPLE_ENV_FILE:-${HOME}/.config/personal-memory/example.env}
28
+ required: false
29
+ volumes:
30
+ - example-data:/data
31
+ restart: unless-stopped
32
+ healthcheck:
33
+ # The image has no curl in most cases; use the interpreter that is there.
34
+ test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=5)"]
35
+ interval: 15s
36
+ timeout: 10s
37
+ retries: 10
38
+ start_period: 30s
39
+
40
+ indexer:
41
+ environment:
42
+ # Selects indexer/sinks/example.py and gives it the API base URL (the compose service name, not localhost).
43
+ MEMORY_SINK: example
44
+ MEMORY_API_URL: http://example:8080
45
+ depends_on:
46
+ # Hard dependency (required defaults to true): this fragment is only layered together with --profile example,
47
+ # so a missing profile fails loudly instead of starting an indexer with nothing to write to.
48
+ example:
49
+ condition: service_healthy
50
+
51
+ volumes:
52
+ example-data: