@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,329 @@
1
+ """Cognee sink: the self-hosted REST server (`cognee/cognee`), stdlib only.
2
+
3
+ Namespace mapping: `bank` maps to a Cognee dataset named `<slug>_<8 hex of sha256(bank)>`, where the slug is the
4
+ bank lowercased, runs of characters outside [a-z0-9] collapsed to "_", stripped, truncated to 40 characters
5
+ (`bank` when empty). Dataset names reach file and database paths on the server, so they stay in [a-z0-9_]; the hash
6
+ suffix keeps two banks that slug identically apart. Cognee's data and delete endpoints take the dataset UUID, so the
7
+ sink resolves name -> id once (`POST /api/v1/datasets` is create-or-return) and caches it for the instance.
8
+
9
+ Document mapping: Cognee has no client-chosen document id and `POST /api/v1/add` never updates a document (same
10
+ name with different content is a 409). The sink therefore stores `doc_id` in the document's `external_metadata`
11
+ and upserts by lookup: page through `GET /api/v1/datasets/{id}/data` (limit 1000), pick every item whose
12
+ `externalMetadata.doc_id` equals the id, `DELETE /api/v1/datasets/{id}/data/{data_id}` each, then add the new
13
+ version. The upload file name is `<sha256(doc_id)[:16]>-<8 hex of sha256(content)>.md`: unique per version (so a
14
+ changed document never collides with an old one) and free of path text. The add request addresses the dataset by
15
+ `datasetName` (the same name cognify takes), so it also works if the cached dataset id went stale.
16
+
17
+ `add` is quick; building the graph is the slow, separate `POST /api/v1/cognify` step, so
18
+ `capabilities.async_ingest` is True and a retained document is not searchable until cognify finishes.
19
+
20
+ Flush: `flush(bank)` (optional hook, see `sinks.flush_sink`) always means "process this bank now". The indexer
21
+ calls it with an explicit bank after a changed batch or when an earlier flush is still pending, possibly in a fresh
22
+ process, so it never relies on this instance's memory: it resolves the dataset by name (cached id, else
23
+ `GET /api/v1/datasets`; never created), and when the dataset is missing or holds no data item
24
+ (`GET .../data?limit=1`) it sends nothing (cognify on an empty dataset is an error; this covers delete-only
25
+ batches that emptied a bank). Otherwise it sends one `POST /api/v1/cognify {"datasets": [name], "runInBackground":
26
+ true}`. `flush(None)` flushes only the banks this instance dirtied (retain or a delete that removed something) and
27
+ is meant for direct callers and tests; the indexer never uses it. The dirty flag is cleared on success or when
28
+ there was nothing to process, never on an error.
29
+
30
+ Document text: Cognee deduplicates an add by content hash within a dataset (and owner), so two documents with
31
+ different `doc_id` and identical bytes would collapse into one row (the second add keeps the first's metadata, and
32
+ deleting either removes the shared content). The uploaded text is therefore `content` plus a footer line
33
+ `<!-- openmem:doc:<first 16 hex of sha256(doc_id)> -->`, which makes every document's bytes unique and
34
+ leaks no id or path text.
35
+
36
+ Delete: a bank with no dataset (looked up by name, never created) has nothing to delete. A 404 on listing (dataset
37
+ gone) or on the data DELETE (data gone) is swallowed. Cognee answers 401 for "dataset missing or no permission"
38
+ and 403 for permission, which cannot be told apart from an auth failure, so 401 and 403 always raise.
39
+
40
+ Auth: env `COGNEE_API_KEY`, read in `create`, is sent as `X-Api-Key` on every request when set. It never appears
41
+ in URLs, errors or logs. URL: `create(url)`, else env `MEMORY_API_URL`; there is no default. Every request goes
42
+ through `sinks._http.open_url`: no redirect is ever followed (so the key cannot reach another host and a write is
43
+ never replayed as a GET) and a malformed HTTP reply is an `OSError`.
44
+
45
+ Failure windows. Upsert is delete-then-add, so between the two a document is missing until the retry; the indexer
46
+ retries because it saves a document's digest only after `retain` returns. A client-side add timeout can leave the
47
+ add completed on the server; the retry then deletes that copy and adds again, which is harmless. A cognify
48
+ requested while the previous pipeline run for the dataset is still running may be skipped or queued by Cognee
49
+ (unverified); the indexer's pending marker only guarantees that a cognify call is issued after the last change, so
50
+ check `GET /api/v1/datasets/status?dataset=<uuid>` when a document does not become searchable
51
+ (the live 1.6.5 API takes dataset UUIDs, not names).
52
+
53
+ Listing metadata: `externalMetadata` / `external_metadata` may be a dict, a list of objects, or a JSON string of
54
+ either (same shapes as `providers/cognee/lib/smoke.mjs` `docIds`); `_matching` accepts all of them. Not verified
55
+ offline against a real server: which of those shapes a given Cognee build actually returns, what `name` the server
56
+ stores for an uploaded file (the sink never reads it), that a `.md` upload with `text/markdown` is accepted by the
57
+ loaders, and the exact cognify behaviour for a dataset that is already processing.
58
+ """
59
+
60
+ import hashlib
61
+ import json
62
+ import os
63
+ import re
64
+ import urllib.error
65
+ import urllib.parse
66
+ import urllib.request
67
+ import uuid
68
+ from collections.abc import Mapping
69
+
70
+ from sinks import Capabilities, SinkError
71
+ from sinks._http import open_url, read_all
72
+
73
+ PAGE = 1000
74
+ SLUG_MAX = 40
75
+ ADD_TIMEOUT = 300
76
+ TIMEOUT = 60
77
+ HEALTH_TIMEOUT = 5
78
+
79
+
80
+ class HttpError(SinkError):
81
+ def __init__(self, method: str, url: str, status: int, body: str):
82
+ super().__init__(f"{method} {url}: HTTP {status}: {body}", status)
83
+
84
+
85
+ def dataset_name(bank: str) -> str:
86
+ """The Cognee dataset name for `bank`: `<slug>_<8 hex of sha256(bank)>`."""
87
+ slug = re.sub(r"[^a-z0-9]+", "_", bank.lower()).strip("_")[:SLUG_MAX].strip("_") or "bank"
88
+ return f"{slug}_{hashlib.sha256(bank.encode()).hexdigest()[:8]}"
89
+
90
+
91
+ def upload_name(doc_id: str, content: str) -> str:
92
+ """Upload file name: unique per document version, with no doc id or path text in it."""
93
+ return f"{hashlib.sha256(doc_id.encode()).hexdigest()[:16]}-{hashlib.sha256(content.encode()).hexdigest()[:8]}.md"
94
+
95
+
96
+ def upload_text(doc_id: str, content: str) -> str:
97
+ """`content` plus a per-document footer, so identical texts of two documents never share a content hash."""
98
+ return f"{content}\n\n<!-- openmem:doc:{hashlib.sha256(doc_id.encode()).hexdigest()[:16]} -->\n"
99
+
100
+
101
+ def metadata_doc_ids(item: Mapping) -> list[str]:
102
+ """Every `doc_id` string in an item's external metadata (parity with smoke.mjs `docIds`)."""
103
+ meta = item.get("externalMetadata")
104
+ if meta is None:
105
+ meta = item.get("external_metadata")
106
+ if isinstance(meta, str):
107
+ try:
108
+ meta = json.loads(meta)
109
+ except json.JSONDecodeError:
110
+ return []
111
+ entries = meta if isinstance(meta, list) else [meta] if meta else []
112
+ return [str(entry["doc_id"]) for entry in entries if isinstance(entry, dict) and isinstance(entry.get("doc_id"), str)]
113
+
114
+
115
+ def multipart(fields: Mapping[str, str], filename: str, content: bytes) -> tuple[bytes, str]:
116
+ """Build a multipart/form-data body with text `fields` and one `data` file part. Returns (body, content type)."""
117
+ boundary = "----cognee" + uuid.uuid4().hex
118
+ parts: list[bytes] = []
119
+ for name, value in fields.items():
120
+ parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n'.encode() + value.encode() + b"\r\n")
121
+ parts.append(
122
+ f'--{boundary}\r\nContent-Disposition: form-data; name="data"; filename="{filename}"\r\nContent-Type: text/markdown\r\n\r\n'.encode()
123
+ + content
124
+ + b"\r\n"
125
+ )
126
+ parts.append(f"--{boundary}--\r\n".encode())
127
+ return b"".join(parts), f"multipart/form-data; boundary={boundary}"
128
+
129
+
130
+ def clean_label(tag: str) -> str:
131
+ """Cognee labels cannot contain commas."""
132
+ return tag.replace(",", "_").strip()
133
+
134
+
135
+ class CogneeSink:
136
+ capabilities = Capabilities(async_ingest=True)
137
+
138
+ def __init__(self, url: str, api_key: str | None = None):
139
+ self.url = url.rstrip("/")
140
+ self._api_key = api_key or None
141
+ self._datasets: dict[str, str] = {}
142
+ self._dirty: set[str] = set()
143
+
144
+ # HTTP
145
+
146
+ def _headers(self, extra: Mapping[str, str] | None = None) -> dict[str, str]:
147
+ headers = dict(extra or {})
148
+ if self._api_key:
149
+ headers["X-Api-Key"] = self._api_key
150
+ return headers
151
+
152
+ def _request(self, method: str, path: str, payload: dict | None = None, *, raw: bytes | None = None, content_type: str | None = None, timeout: int = TIMEOUT):
153
+ url = self.url + path
154
+ if raw is not None:
155
+ data, extra = raw, {"Content-Type": content_type or "application/octet-stream"}
156
+ elif payload is not None:
157
+ data, extra = json.dumps(payload).encode(), {"Content-Type": "application/json"}
158
+ else:
159
+ data, extra = None, {}
160
+ request = urllib.request.Request(url, data=data, headers=self._headers(extra), method=method)
161
+ try:
162
+ with open_url(request, timeout) as response:
163
+ body = read_all(response)
164
+ except urllib.error.HTTPError as error:
165
+ text = read_all(error).decode(errors="replace")
166
+ if self._api_key:
167
+ text = text.replace(self._api_key, "***")
168
+ raise HttpError(method, url, error.code, text[:300]) from error
169
+ if not body:
170
+ return {}
171
+ try:
172
+ return json.loads(body)
173
+ except ValueError:
174
+ return {}
175
+
176
+ def health(self) -> bool:
177
+ try:
178
+ request = urllib.request.Request(self.url + "/health", headers=self._headers())
179
+ with open_url(request, HEALTH_TIMEOUT) as response:
180
+ return 200 <= response.status < 300
181
+ except (OSError, ValueError): # includes HTTPError (3xx and errors), URLError and malformed replies
182
+ return False
183
+
184
+ # Datasets
185
+
186
+ def prepare(self, bank: str) -> None:
187
+ self._dataset_id(bank)
188
+
189
+ def _dataset_id(self, bank: str) -> str:
190
+ if bank not in self._datasets:
191
+ response = self._request("POST", "/api/v1/datasets", {"name": dataset_name(bank)})
192
+ dataset_id = response.get("id") if isinstance(response, dict) else None
193
+ if not dataset_id:
194
+ raise SinkError(f"POST {self.url}/api/v1/datasets: reply carries no dataset id")
195
+ self._datasets[bank] = str(dataset_id)
196
+ return self._datasets[bank]
197
+
198
+ def _find_dataset(self, bank: str) -> str | None:
199
+ """The dataset id for `bank` when it exists; never creates it."""
200
+ if bank in self._datasets:
201
+ return self._datasets[bank]
202
+ name = dataset_name(bank)
203
+ response = self._request("GET", "/api/v1/datasets")
204
+ for item in response if isinstance(response, list) else []:
205
+ if isinstance(item, dict) and item.get("name") == name and item.get("id"):
206
+ self._datasets[bank] = str(item["id"])
207
+ return self._datasets[bank]
208
+ return None
209
+
210
+ def _list_data(self, dataset_id: str) -> list[dict]:
211
+ items: list[dict] = []
212
+ offset = 0
213
+ while True:
214
+ page = self._request("GET", f"/api/v1/datasets/{urllib.parse.quote(dataset_id, safe='')}/data?limit={PAGE}&offset={offset}")
215
+ if not isinstance(page, list):
216
+ raise SinkError(f"GET dataset data: expected a list, got {type(page).__name__}")
217
+ items.extend(item for item in page if isinstance(item, dict))
218
+ if len(page) < PAGE:
219
+ return items
220
+ offset += len(page)
221
+
222
+ def _matching(self, dataset_id: str, doc_id: str) -> list[str]:
223
+ """Ids of the dataset's items whose external metadata carries `doc_id` (dict, list, or JSON string)."""
224
+ ids = []
225
+ for item in self._list_data(dataset_id):
226
+ if doc_id in metadata_doc_ids(item) and item.get("id"):
227
+ ids.append(str(item["id"]))
228
+ return ids
229
+
230
+ def _delete_data(self, dataset_id: str, data_ids: list[str]) -> None:
231
+ for data_id in data_ids:
232
+ try:
233
+ self._request("DELETE", f"/api/v1/datasets/{urllib.parse.quote(dataset_id, safe='')}/data/{urllib.parse.quote(data_id, safe='')}")
234
+ except HttpError as error:
235
+ # 404: the data is already gone. 401/403 are auth failures and raise.
236
+ if error.status != 404:
237
+ raise
238
+
239
+ # Documents
240
+
241
+ def retain(
242
+ self,
243
+ bank: str,
244
+ doc_id: str,
245
+ content: str,
246
+ *,
247
+ tags: list[str] | None = None,
248
+ metadata: Mapping[str, str] | None = None,
249
+ context: str | None = None,
250
+ timestamp: str | None = None,
251
+ ) -> None:
252
+ dataset_id = self._dataset_id(bank)
253
+ try:
254
+ existing = self._matching(dataset_id, doc_id)
255
+ except HttpError as error:
256
+ if error.status != 404:
257
+ raise
258
+ # The cached dataset was removed behind our back: recreate it and look again.
259
+ self._datasets.pop(bank, None)
260
+ dataset_id = self._dataset_id(bank)
261
+ existing = self._matching(dataset_id, doc_id)
262
+ self._delete_data(dataset_id, existing)
263
+ meta: dict = {str(key): str(value) for key, value in (metadata or {}).items()}
264
+ meta["doc_id"] = doc_id
265
+ meta["bank"] = bank
266
+ if tags:
267
+ meta["tags"] = list(tags)
268
+ if context is not None:
269
+ meta["context"] = context
270
+ if timestamp is not None:
271
+ meta["timestamp"] = timestamp
272
+ fields = {"datasetName": dataset_name(bank), "external_metadata": json.dumps([meta])}
273
+ if tags:
274
+ fields["labels"] = json.dumps([clean_label(tags[0])])
275
+ body, content_type = multipart(fields, upload_name(doc_id, content), upload_text(doc_id, content).encode())
276
+ self._request("POST", "/api/v1/add", raw=body, content_type=content_type, timeout=ADD_TIMEOUT)
277
+ self._dirty.add(bank)
278
+
279
+ def delete(self, bank: str, doc_id: str) -> None:
280
+ dataset_id = self._find_dataset(bank)
281
+ if dataset_id is None:
282
+ return
283
+ try:
284
+ existing = self._matching(dataset_id, doc_id)
285
+ except HttpError as error:
286
+ # 404: the dataset is gone, so is the document.
287
+ if error.status != 404:
288
+ raise
289
+ self._datasets.pop(bank, None)
290
+ return
291
+ if existing:
292
+ self._delete_data(dataset_id, existing)
293
+ self._dirty.add(bank)
294
+
295
+ def flush(self, bank: str | None = None) -> None:
296
+ """`flush(bank)`: run cognify for `bank` now (see the module docstring). `flush(None)`: every dirty bank."""
297
+ for name in sorted(self._dirty) if bank is None else [bank]:
298
+ self._cognify(name)
299
+
300
+ def _peek(self, dataset_id: str) -> list | None:
301
+ """One data item of the dataset ([] when empty), or None when the dataset is gone (404)."""
302
+ try:
303
+ page = self._request("GET", f"/api/v1/datasets/{urllib.parse.quote(dataset_id, safe='')}/data?limit=1&offset=0")
304
+ except HttpError as error:
305
+ if error.status != 404:
306
+ raise
307
+ return None
308
+ return page if isinstance(page, list) else []
309
+
310
+ def _cognify(self, bank: str) -> None:
311
+ dataset_id = self._find_dataset(bank)
312
+ page = self._peek(dataset_id) if dataset_id else None
313
+ if page is None and dataset_id:
314
+ # The cached id went stale: look the dataset up by name once more.
315
+ self._datasets.pop(bank, None)
316
+ dataset_id = self._find_dataset(bank)
317
+ page = self._peek(dataset_id) if dataset_id else None
318
+ if page:
319
+ self._request("POST", "/api/v1/cognify", {"datasets": [dataset_name(bank)], "runInBackground": True})
320
+ self._dirty.discard(bank)
321
+
322
+
323
+ def create(url: str | None = None, **_cfg) -> CogneeSink:
324
+ url = url or os.environ.get("MEMORY_API_URL")
325
+ if not url:
326
+ raise SinkError("the cognee sink needs a server URL: pass --url or set MEMORY_API_URL")
327
+ if urllib.parse.urlsplit(url).scheme not in ("http", "https"):
328
+ raise SinkError("the cognee sink URL must start with http:// or https://")
329
+ return CogneeSink(url, os.environ.get("COGNEE_API_KEY"))
@@ -0,0 +1,107 @@
1
+ """Hindsight sink: REST `/v1/default/banks/{bank}/...`, moved verbatim from bin/hindsight-project-index.
2
+
3
+ Namespace mapping: `bank` is the Hindsight bank id, percent-encoded into the URL path.
4
+ Retains are synchronous (`"async": false`) with the `project-index` retain strategy; `prepare` creates the bank
5
+ when missing and pins that strategy and disables auto-consolidation.
6
+ """
7
+
8
+ import json
9
+ import urllib.error
10
+ import urllib.parse
11
+ import urllib.request
12
+ from collections.abc import Mapping
13
+
14
+ from sinks import Capabilities, SinkError
15
+
16
+ DEFAULT_URL = "http://127.0.0.1:8888"
17
+ STRATEGY = "project-index"
18
+
19
+
20
+ class HttpError(SinkError):
21
+ def __init__(self, method: str, url: str, status: int, body: str):
22
+ super().__init__(f"{method} {url}: HTTP {status}: {body}", status)
23
+
24
+
25
+ def request(url: str, method: str, payload: dict | None = None) -> dict:
26
+ data = json.dumps(payload).encode() if payload is not None else None
27
+ headers = {"Content-Type": "application/json"} if data is not None else {}
28
+ try:
29
+ with urllib.request.urlopen(urllib.request.Request(url, data=data, headers=headers, method=method), timeout=300) as response:
30
+ body = response.read()
31
+ except urllib.error.HTTPError as error:
32
+ raise HttpError(method, url, error.code, error.read().decode(errors="replace")[:300]) from error
33
+ return json.loads(body) if body else {}
34
+
35
+
36
+ def ensure_strategy(endpoint: str) -> None:
37
+ strategy = {"retain_extraction_mode": "chunks", "retain_chunk_size": 3000}
38
+ try:
39
+ response = request(endpoint + "/config", "GET")
40
+ except HttpError as error:
41
+ if error.status != 404:
42
+ raise
43
+ request(endpoint, "PUT", {})
44
+ response = request(endpoint + "/config", "GET")
45
+ config = response.get("config", response)
46
+ strategies = config.get("retain_strategies") or {}
47
+ updates = {}
48
+ if strategies.get(STRATEGY) != strategy:
49
+ updates["retain_strategies"] = {**strategies, STRATEGY: strategy}
50
+ if config.get("enable_auto_consolidation") is not False:
51
+ updates["enable_auto_consolidation"] = False
52
+ if updates:
53
+ request(endpoint + "/config", "PATCH", {"updates": updates})
54
+
55
+
56
+ class HindsightSink:
57
+ capabilities = Capabilities(async_ingest=False)
58
+
59
+ def __init__(self, url: str = DEFAULT_URL):
60
+ self.url = url.rstrip("/")
61
+
62
+ def _endpoint(self, bank: str) -> str:
63
+ return f"{self.url}/v1/default/banks/{urllib.parse.quote(bank, safe='')}"
64
+
65
+ def health(self) -> bool:
66
+ try:
67
+ with urllib.request.urlopen(self.url + "/health", timeout=5) as response:
68
+ return 200 <= response.status < 300
69
+ except (OSError, ValueError):
70
+ return False
71
+
72
+ def prepare(self, bank: str) -> None:
73
+ ensure_strategy(self._endpoint(bank))
74
+
75
+ def retain(
76
+ self,
77
+ bank: str,
78
+ doc_id: str,
79
+ content: str,
80
+ *,
81
+ tags: list[str] | None = None,
82
+ metadata: Mapping[str, str] | None = None,
83
+ context: str | None = None,
84
+ timestamp: str | None = None,
85
+ ) -> None:
86
+ item: dict = {"content": content, "document_id": doc_id}
87
+ if context is not None:
88
+ item["context"] = context
89
+ if metadata is not None:
90
+ item["metadata"] = dict(metadata)
91
+ item["timestamp"] = timestamp if timestamp is not None else "unset"
92
+ item["strategy"] = STRATEGY
93
+ if tags:
94
+ item["tags"] = list(tags)
95
+ request(self._endpoint(bank) + "/memories", "POST", {"items": [item], "async": False})
96
+
97
+ def delete(self, bank: str, doc_id: str) -> None:
98
+ try:
99
+ request(self._endpoint(bank) + "/documents/" + urllib.parse.quote(doc_id, safe=""), "DELETE")
100
+ except HttpError as error:
101
+ # 404: already gone (deleted server-side or never retained).
102
+ if error.status != 404:
103
+ raise
104
+
105
+
106
+ def create(url: str | None = None, **_cfg) -> HindsightSink:
107
+ return HindsightSink(url or DEFAULT_URL)