@bcmyguest/openmem 0.0.0-stage → 0.8.0

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 (230) hide show
  1. package/.agents/skills/openmem/SKILL.md +34 -0
  2. package/LICENSE +190 -0
  3. package/README.md +140 -2
  4. package/bin/hindsight-project-index +530 -0
  5. package/cli/coding-agent/access-policy.mjs +38 -0
  6. package/cli/coding-agent/agents.mjs +34 -0
  7. package/cli/coding-agent/bank-from-cwd.mjs +140 -0
  8. package/cli/coding-agent/capabilities.mjs +34 -0
  9. package/cli/coding-agent/context-store.mjs +81 -0
  10. package/cli/coding-agent/context-trim.mjs +897 -0
  11. package/cli/coding-agent/file-io.mjs +72 -0
  12. package/cli/coding-agent/hook-harnesses.mjs +88 -0
  13. package/cli/coding-agent/hook-runner.mjs +284 -0
  14. package/cli/coding-agent/install-access.mjs +119 -0
  15. package/cli/coding-agent/install-context-proxy.mjs +333 -0
  16. package/cli/coding-agent/install-rtk.mjs +60 -0
  17. package/cli/coding-agent/install-shim.mjs +201 -0
  18. package/cli/coding-agent/opencode-config.mjs +107 -0
  19. package/cli/coding-agent/opencode-plugin.mjs +132 -0
  20. package/cli/coding-agent/opencode2-plugin/index.js +299 -0
  21. package/cli/coding-agent/recall-frame.mjs +29 -0
  22. package/cli/coding-agent/remove-upstream-access.mjs +122 -0
  23. package/cli/coding-agent/runtime.mjs +40 -0
  24. package/cli/coding-agent/session-db.mjs +626 -0
  25. package/cli/coding-agent/session-facade.mjs +86 -0
  26. package/cli/coding-agent/toml-edit.mjs +161 -0
  27. package/cli/cognee-mcp.mjs +260 -0
  28. package/cli/context-management.mjs +254 -0
  29. package/cli/feed-sync.mjs +27 -0
  30. package/cli/install-coding-agent.mjs +298 -0
  31. package/cli/install-memory-mcp.mjs +426 -0
  32. package/cli/is-main.mjs +18 -0
  33. package/cli/mcp/context-sql-runner.mjs +58 -0
  34. package/cli/mcp/context-store.mjs +670 -0
  35. package/cli/mcp/context.mjs +61 -0
  36. package/cli/mcp/export/cognee-dataset-name.mjs +12 -0
  37. package/cli/mcp/export/cognee.mjs +119 -0
  38. package/cli/mcp/export/hindsight.mjs +48 -0
  39. package/cli/mcp/export/index.mjs +26 -0
  40. package/cli/mcp/export/mem0.mjs +31 -0
  41. package/cli/mcp/knowledge.mjs +333 -0
  42. package/cli/mcp/llm-synthesize.mjs +50 -0
  43. package/cli/mcp/mem0-scope-id.mjs +22 -0
  44. package/cli/mcp/memory-facade.mjs +521 -0
  45. package/cli/mcp/memory-reflect-consolidate.mjs +120 -0
  46. package/cli/mcp/notebooklm-banks.mjs +21 -0
  47. package/cli/mcp/notebooklm-hindsight-rest.mjs +45 -0
  48. package/cli/mcp/notebooklm-provider.mjs +225 -0
  49. package/cli/mcp/pmm-records.mjs +23 -0
  50. package/cli/mcp/portability.mjs +158 -0
  51. package/cli/mcp/stdio.mjs +65 -0
  52. package/cli/mcp/unified-mcp-install.mjs +65 -0
  53. package/cli/mem0-mcp.mjs +108 -0
  54. package/cli/notebooklm-mcp.mjs +236 -0
  55. package/cli/openmem-mcp.mjs +38 -0
  56. package/cli/openmem.mjs +222 -0
  57. package/cli/roots.mjs +85 -0
  58. package/cli/schedule.mjs +176 -0
  59. package/cli/stack.mjs +438 -0
  60. package/cli/switch-memory-provider.mjs +358 -0
  61. package/cli/wiring-report.mjs +125 -0
  62. package/compose.bench.yaml +12 -0
  63. package/compose.yaml +227 -0
  64. package/config/coding-agent.json +13 -0
  65. package/config/hindsight.env.example +26 -0
  66. package/config/index.yml +54 -0
  67. package/config/litellm.lemond.yaml +40 -0
  68. package/config/litellm.ollama.yaml +33 -0
  69. package/config/notebooklm.yml +33 -0
  70. package/config/openmem.toml.example +124 -0
  71. package/docs/OPENMEM.md +23 -0
  72. package/docs/architecture.md +245 -0
  73. package/docs/assets/README.md +10 -0
  74. package/docs/assets/setup-demo.gif +0 -0
  75. package/docs/coding-agent-hooks.md +75 -0
  76. package/docs/coding-agent-scopes.md +22 -0
  77. package/docs/coding-agents.md +71 -0
  78. package/docs/cognee.md +37 -0
  79. package/docs/compatibility.md +80 -0
  80. package/docs/concepts.md +135 -0
  81. package/docs/context-management.md +255 -0
  82. package/docs/context-proxy.md +115 -0
  83. package/docs/context-research.md +215 -0
  84. package/docs/context-store.md +277 -0
  85. package/docs/hindsight.md +77 -0
  86. package/docs/index-sources-roadmap.md +38 -0
  87. package/docs/index-sources.md +39 -0
  88. package/docs/indexer-sinks.md +122 -0
  89. package/docs/indexing.md +57 -0
  90. package/docs/manual-stack-setup.md +74 -0
  91. package/docs/mcp-install-guide.md +4 -0
  92. package/docs/mcp-setup.md +104 -0
  93. package/docs/mem0.md +141 -0
  94. package/docs/memory-portability.md +73 -0
  95. package/docs/memory-providers.md +246 -0
  96. package/docs/meridian-litellm.md +198 -0
  97. package/docs/model-configuration.md +106 -0
  98. package/docs/notebooklm.md +83 -0
  99. package/docs/npx-install.md +172 -0
  100. package/docs/openmem-config.md +194 -0
  101. package/docs/provider-wizard-hooks.md +222 -0
  102. package/docs/quickstart.md +107 -0
  103. package/docs/setup-wizard.md +117 -0
  104. package/docs/sources-feeds.md +39 -0
  105. package/docs/sources-readwise.md +23 -0
  106. package/docs/sources-zotero.md +22 -0
  107. package/glama.json +4 -0
  108. package/indexer/Dockerfile +38 -0
  109. package/indexer/code_outline.py +146 -0
  110. package/indexer/feed_sync.py +107 -0
  111. package/indexer/index_config.py +290 -0
  112. package/indexer/pytest.ini +5 -0
  113. package/indexer/sinks/__init__.py +148 -0
  114. package/indexer/sinks/_http.py +44 -0
  115. package/indexer/sinks/cognee.py +329 -0
  116. package/indexer/sinks/hindsight.py +107 -0
  117. package/indexer/sinks/mem0.py +247 -0
  118. package/indexer/watch.py +240 -0
  119. package/package.json +96 -4
  120. package/providers/_template/compose.yaml +52 -0
  121. package/providers/cognee/OPERATOR.md +82 -0
  122. package/providers/cognee/compose.yaml +89 -0
  123. package/providers/cognee/lib/clack.mjs +157 -0
  124. package/providers/cognee/lib/coding-agent.mjs +26 -0
  125. package/providers/cognee/lib/cognee-mcp-install.mjs +153 -0
  126. package/providers/cognee/lib/config.mjs +98 -0
  127. package/providers/cognee/lib/env-file.mjs +194 -0
  128. package/providers/cognee/lib/env-setup.mjs +83 -0
  129. package/providers/cognee/lib/llm-env.mjs +382 -0
  130. package/providers/cognee/lib/ports.mjs +25 -0
  131. package/providers/cognee/lib/smoke.mjs +246 -0
  132. package/providers/cognee/lib/stack-env.mjs +134 -0
  133. package/providers/cognee/lib/state.mjs +101 -0
  134. package/providers/cognee/lib/summary.mjs +56 -0
  135. package/providers/cognee/lib/validate.mjs +97 -0
  136. package/providers/cognee/manifest.json +56 -0
  137. package/providers/cognee/wizard.mjs +134 -0
  138. package/providers/graphiti/manifest.json +15 -0
  139. package/providers/hindsight/lib/clack.mjs +347 -0
  140. package/providers/hindsight/lib/coding-agent.mjs +204 -0
  141. package/providers/hindsight/lib/config.mjs +79 -0
  142. package/providers/hindsight/lib/env-setup.mjs +72 -0
  143. package/providers/hindsight/lib/litellm-routes.mjs +196 -0
  144. package/providers/hindsight/lib/meridian.mjs +307 -0
  145. package/providers/hindsight/lib/model-backend.mjs +243 -0
  146. package/providers/hindsight/lib/notebooklm-mcp-install.mjs +127 -0
  147. package/providers/hindsight/lib/ollama-compose.mjs +51 -0
  148. package/providers/hindsight/lib/ports.mjs +30 -0
  149. package/providers/hindsight/lib/probe.mjs +54 -0
  150. package/providers/hindsight/lib/stack-env.mjs +125 -0
  151. package/providers/hindsight/lib/state.mjs +80 -0
  152. package/providers/hindsight/lib/summary.mjs +57 -0
  153. package/providers/hindsight/lib/validate.mjs +81 -0
  154. package/providers/hindsight/manifest.json +58 -0
  155. package/providers/hindsight/wizard.mjs +150 -0
  156. package/providers/letta/manifest.json +29 -0
  157. package/providers/mem0/OPERATOR.md +143 -0
  158. package/providers/mem0/compose.yaml +79 -0
  159. package/providers/mem0/lib/coding-agent.mjs +58 -0
  160. package/providers/mem0/lib/config.mjs +45 -0
  161. package/providers/mem0/lib/configure.mjs +201 -0
  162. package/providers/mem0/lib/dotenv.mjs +71 -0
  163. package/providers/mem0/lib/env-file.mjs +110 -0
  164. package/providers/mem0/lib/http.mjs +69 -0
  165. package/providers/mem0/lib/litellm.mjs +457 -0
  166. package/providers/mem0/lib/mcp-install.mjs +165 -0
  167. package/providers/mem0/lib/secret.mjs +30 -0
  168. package/providers/mem0/lib/smoke.mjs +179 -0
  169. package/providers/mem0/lib/stack-env.mjs +255 -0
  170. package/providers/mem0/lib/state.mjs +67 -0
  171. package/providers/mem0/lib/summary.mjs +88 -0
  172. package/providers/mem0/lib/url.mjs +106 -0
  173. package/providers/mem0/lib/validate.mjs +158 -0
  174. package/providers/mem0/manifest.json +47 -0
  175. package/providers/mem0/wizard.mjs +146 -0
  176. package/providers/zep/manifest.json +29 -0
  177. package/proxy/config.yaml +33 -0
  178. package/proxy/openmem_context/__init__.py +6 -0
  179. package/proxy/openmem_context/adapters.py +504 -0
  180. package/proxy/openmem_context/compress.py +53 -0
  181. package/proxy/openmem_context/core.py +606 -0
  182. package/proxy/openmem_context/embed.py +212 -0
  183. package/proxy/openmem_context/recall.py +49 -0
  184. package/proxy/openmem_context/session.py +70 -0
  185. package/proxy/openmem_context/sessiondb.py +276 -0
  186. package/proxy/openmem_context/settings.py +134 -0
  187. package/proxy/openmem_context/store.py +129 -0
  188. package/proxy/openmem_context/tokens.py +123 -0
  189. package/proxy/openmem_context/ttl.py +78 -0
  190. package/proxy/openmem_context_hook.py +34 -0
  191. package/scripts/check-pack.mjs +219 -0
  192. package/scripts/gen-masks.mjs +83 -0
  193. package/scripts/gen-masks.sh +4 -0
  194. package/scripts/promotion-provider-smoke.mjs +34 -0
  195. package/scripts/run-setup.sh +30 -0
  196. package/scripts/setup.mjs +107 -0
  197. package/scripts/setup.sh +5 -0
  198. package/scripts/spin-up.sh +45 -0
  199. package/scripts/wizard.sh +5 -0
  200. package/server.json +34 -0
  201. package/sync/Dockerfile +27 -0
  202. package/sync/memory_backend.py +87 -0
  203. package/sync/notebooklm_sync.py +664 -0
  204. package/wizard/bin/pm.mjs +97 -0
  205. package/wizard/bin/setup.mjs +33 -0
  206. package/wizard/lib/compose-version.mjs +32 -0
  207. package/wizard/lib/compose.mjs +70 -0
  208. package/wizard/lib/context-step.mjs +140 -0
  209. package/wizard/lib/curl.mjs +103 -0
  210. package/wizard/lib/docker-build.mjs +148 -0
  211. package/wizard/lib/exec.mjs +41 -0
  212. package/wizard/lib/indexer.mjs +6 -0
  213. package/wizard/lib/litellm-env.mjs +55 -0
  214. package/wizard/lib/load-machine-env.mjs +4 -0
  215. package/wizard/lib/log.mjs +32 -0
  216. package/wizard/lib/notebooklm-agent-access.mjs +92 -0
  217. package/wizard/lib/notebooklm.mjs +214 -0
  218. package/wizard/lib/parse-args.mjs +87 -0
  219. package/wizard/lib/paths.mjs +39 -0
  220. package/wizard/lib/pm-config.mjs +371 -0
  221. package/wizard/lib/ports.mjs +94 -0
  222. package/wizard/lib/prompts.mjs +85 -0
  223. package/wizard/lib/provider-wizard/helpers.mjs +98 -0
  224. package/wizard/lib/provider-wizard/run-steps.mjs +129 -0
  225. package/wizard/lib/providers/registry.mjs +262 -0
  226. package/wizard/lib/run-clack.mjs +108 -0
  227. package/wizard/lib/run-wizard.mjs +750 -0
  228. package/wizard/lib/validate-setup.mjs +62 -0
  229. package/wizard/lib/wizard-action.mjs +34 -0
  230. package/wizard/lib/wizard-conf.mjs +71 -0
@@ -0,0 +1,148 @@
1
+ """Memory sinks: where the indexer writes documents.
2
+
3
+ `bin/hindsight-project-index` produces documents (stable id, text, tags, metadata) and hands them to a sink. A sink
4
+ is any object satisfying the `Sink` protocol below. The indexer holds no provider knowledge beyond choosing a sink
5
+ with `--sink NAME` (env `MEMORY_SINK`, default `hindsight`).
6
+
7
+ Import name: `indexer/` is the import root (PYTHONPATH=/opt/indexer in the image, `indexer/` on sys.path in the
8
+ tests), so this package imports as `sinks`, next to `index_config` and `code_outline`.
9
+
10
+ Adding a sink: drop `indexer/sinks/<name>.py` that defines `create(url=None, **cfg) -> Sink`. Nothing else is
11
+ edited: `get_sink` finds the module by name, and the Dockerfile copies the whole directory. `<name>` is the
12
+ provider id (lowercase letters, digits, underscore) and is what `--sink` and `MEMORY_SINK` accept.
13
+
14
+ Namespace ("bank") mapping. The indexer says `bank` for the logical grouping a document belongs to (a repository's
15
+ bank, an Obsidian vault's bank; for example `coding-agent::proj` or `obsidian::my-vault`). Hindsight uses it
16
+ verbatim as the bank id. A provider without banks maps it to its nearest scoping concept and documents the rule in
17
+ its own module docstring:
18
+
19
+ mem0 bank -> user_id (agent_id may hold a fixed indexer name)
20
+ cognee bank -> dataset name
21
+
22
+ The bank is an opaque, non-empty string: sinks must quote or sanitize it for their own API, and must not assume
23
+ it is URL-safe. `doc_id` is a stable string (`project-index:v1:<hash>`); the same id always names the same
24
+ logical document within a bank.
25
+
26
+ Errors. HTTP error replies raise `SinkError` (or a subclass); `SinkError.status` is the HTTP status. A server
27
+ that cannot be reached raises `SinkError` or `OSError` (urllib's URLError is an OSError). `delete` of a document
28
+ that is already gone is NOT an error. `health()` never raises.
29
+
30
+ Optional `flush(bank: str) -> None`. A sink whose provider ingests asynchronously (for example Cognee, where
31
+ cognify builds the graph after add) may define `flush`. The indexer calls `flush(bank)` with an explicit bank,
32
+ once per bank per run, whenever a batch retained or deleted at least one document for that bank OR a previous
33
+ flush for that bank is still pending (it failed, or the run died before reaching it). The pending state is
34
+ persisted in the indexer state file, so a fresh process retries it even when nothing changed. A sink must
35
+ therefore treat `flush(bank)` as "process this bank now", even if its own in-memory bookkeeping says the bank is
36
+ clean. `flush(None)` is never called, and a dry run never flushes. `flush` is NOT part of the `Sink` protocol
37
+ (so sinks without it still satisfy `isinstance`); callers go through `flush_sink`, which does nothing for a sink
38
+ that has no callable `flush`. A flush error is handled like a retain error: it raises `SinkError` (or `OSError`)
39
+ and fails that source, and the pending marker stays so the next run flushes again.
40
+
41
+ URL. `get_sink(name, url=...)` passes the API base URL; the CLI resolves it from `--url`, then `HINDSIGHT_URL`
42
+ for the hindsight sink, or `MEMORY_API_URL` for every other sink. A sink reads any credentials from its own env
43
+ vars inside `create`, never from the document path.
44
+ """
45
+
46
+ import importlib
47
+ import pkgutil
48
+ import re
49
+ from collections.abc import Callable, Mapping
50
+ from dataclasses import dataclass
51
+ from typing import Any, Protocol, runtime_checkable
52
+
53
+ DEFAULT_SINK = "hindsight"
54
+ NAME = re.compile(r"^[a-z][a-z0-9_]*$")
55
+
56
+
57
+ class SinkError(RuntimeError):
58
+ """A sink request failed. `status` is the HTTP status, or None for a connection-level failure."""
59
+
60
+ def __init__(self, message: str, status: int | None = None):
61
+ super().__init__(message)
62
+ self.status = status
63
+
64
+
65
+ class UnknownSinkError(ValueError):
66
+ """The requested sink name is not registered."""
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Capabilities:
71
+ """What a sink can do beyond the basics.
72
+
73
+ async_ingest: retain() may return before the provider has made the document searchable (for example Cognee
74
+ add -> cognify). Callers must not read their own writes immediately.
75
+ """
76
+
77
+ async_ingest: bool = False
78
+
79
+
80
+ @runtime_checkable
81
+ class Sink(Protocol):
82
+ capabilities: Capabilities
83
+
84
+ def health(self) -> bool:
85
+ """True when the provider answers its health endpoint. Never raises."""
86
+ ...
87
+
88
+ def prepare(self, bank: str) -> None:
89
+ """Make `bank` ready for retain (create it, set retain strategy). Called before a batch of retains for
90
+ that bank; must be idempotent and may be a no-op."""
91
+ ...
92
+
93
+ def retain(
94
+ self,
95
+ bank: str,
96
+ doc_id: str,
97
+ content: str,
98
+ *,
99
+ tags: list[str] | None = None,
100
+ metadata: Mapping[str, str] | None = None,
101
+ context: str | None = None,
102
+ timestamp: str | None = None,
103
+ ) -> None:
104
+ """Upsert one document: retaining an existing `doc_id` replaces it, never duplicates it.
105
+
106
+ timestamp None means "no event time"; the sink maps that to the provider's own convention.
107
+ """
108
+ ...
109
+
110
+ def delete(self, bank: str, doc_id: str) -> None:
111
+ """Remove a document. A document that does not exist is not an error."""
112
+ ...
113
+
114
+
115
+ def flush_sink(sink: Any, bank: str) -> None:
116
+ """Call the optional `sink.flush(bank)` when the sink defines it; otherwise do nothing."""
117
+ flush = getattr(sink, "flush", None)
118
+ if callable(flush):
119
+ flush(bank)
120
+
121
+
122
+ _registry: dict[str, Callable[..., Sink]] = {}
123
+
124
+
125
+ def register_sink(name: str, factory: Callable[..., Sink]) -> None:
126
+ """Register a factory under `name` for this process, taking precedence over a module of the same name."""
127
+ if not NAME.match(name):
128
+ raise ValueError(f"invalid sink name {name!r}: use lowercase letters, digits and underscore")
129
+ _registry[name] = factory
130
+
131
+
132
+ def registered_sinks() -> list[str]:
133
+ """Sorted sink names: explicit registrations plus every public module in this package."""
134
+ names = {module.name for module in pkgutil.iter_modules(__path__) if NAME.match(module.name)}
135
+ return sorted(names | set(_registry))
136
+
137
+
138
+ def get_sink(name: str, **cfg: Any) -> Sink:
139
+ """Build the sink `name` with `cfg` (normally `url=`). Raises UnknownSinkError listing the valid names."""
140
+ if name in _registry:
141
+ return _registry[name](**cfg)
142
+ if name not in registered_sinks():
143
+ raise UnknownSinkError(f"unknown sink {name!r}; registered sinks: {', '.join(registered_sinks()) or 'none'}")
144
+ module = importlib.import_module(f"{__name__}.{name}")
145
+ factory = getattr(module, "create", None)
146
+ if not callable(factory):
147
+ raise UnknownSinkError(f"sink module {module.__name__} defines no create(url=None, **cfg) factory")
148
+ return factory(**cfg)
@@ -0,0 +1,44 @@
1
+ """Shared HTTP plumbing for sinks (stdlib only). The leading underscore keeps this module out of the sink registry.
2
+
3
+ `open_url` is the one place a sink opens a URL. It differs from `urllib.request.urlopen` in two ways that every
4
+ REST sink wants:
5
+
6
+ - It follows no redirect at all. A 3xx reply surfaces as `urllib.error.HTTPError` (carrying the status), so a sink
7
+ never silently re-issues a write as a GET, and an API key in the request can never be handed to another host.
8
+ - `http.client.HTTPException` (BadStatusLine, IncompleteRead, LineTooLong, ...) becomes `OSError`. A server that
9
+ speaks garbage is then "unreachable", which is the failure the sink contract already allows (`SinkError` or
10
+ `OSError`), and `health()` can catch `OSError` alone.
11
+ """
12
+
13
+ import http.client
14
+ import urllib.request
15
+
16
+
17
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
18
+ """Refuse every redirect: `redirect_request` returns None, so urllib raises the 3xx as an HTTPError."""
19
+
20
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
21
+ return None
22
+
23
+
24
+ OPENER = urllib.request.build_opener(NoRedirect)
25
+
26
+
27
+ def open_url(request: urllib.request.Request, timeout: float):
28
+ """Open `request` without following redirects. Returns the response (a context manager).
29
+
30
+ Raises `urllib.error.HTTPError` for any non-2xx status (3xx included), `urllib.error.URLError` or another
31
+ `OSError` when the server cannot be reached, and `OSError` for a malformed HTTP reply.
32
+ """
33
+ try:
34
+ return OPENER.open(request, timeout=timeout)
35
+ except http.client.HTTPException as error:
36
+ raise OSError(f"{type(error).__name__}: {error}") from error
37
+
38
+
39
+ def read_all(response) -> bytes:
40
+ """`response.read()` for a response or an HTTPError, with `http.client.HTTPException` turned into `OSError`."""
41
+ try:
42
+ return response.read()
43
+ except http.client.HTTPException as error:
44
+ raise OSError(f"{type(error).__name__}: {error}") from error
@@ -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)