@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.
- package/LICENSE +190 -0
- package/README.md +136 -2
- package/bin/hindsight-project-index +530 -0
- package/cli/coding-agent/access-policy.mjs +38 -0
- package/cli/coding-agent/agents.mjs +34 -0
- package/cli/coding-agent/bank-from-cwd.mjs +140 -0
- package/cli/coding-agent/capabilities.mjs +23 -0
- package/cli/coding-agent/hook-harnesses.mjs +87 -0
- package/cli/coding-agent/hook-runner.mjs +260 -0
- package/cli/coding-agent/install-access.mjs +93 -0
- package/cli/coding-agent/install-shim.mjs +97 -0
- package/cli/coding-agent/opencode-config.mjs +91 -0
- package/cli/coding-agent/opencode-plugin.mjs +96 -0
- package/cli/coding-agent/runtime.mjs +39 -0
- package/cli/coding-agent/session-facade.mjs +62 -0
- package/cli/cognee-mcp.mjs +260 -0
- package/cli/feed-sync.mjs +11 -0
- package/cli/install-coding-agent.mjs +165 -0
- package/cli/install-memory-mcp.mjs +426 -0
- package/cli/is-main.mjs +18 -0
- package/cli/mcp/context.mjs +61 -0
- package/cli/mcp/export/cognee-dataset-name.mjs +12 -0
- package/cli/mcp/export/cognee.mjs +119 -0
- package/cli/mcp/export/hindsight.mjs +48 -0
- package/cli/mcp/export/index.mjs +26 -0
- package/cli/mcp/export/mem0.mjs +31 -0
- package/cli/mcp/llm-synthesize.mjs +50 -0
- package/cli/mcp/mem0-scope-id.mjs +22 -0
- package/cli/mcp/memory-facade.mjs +455 -0
- package/cli/mcp/memory-reflect-consolidate.mjs +120 -0
- package/cli/mcp/notebooklm-banks.mjs +21 -0
- package/cli/mcp/notebooklm-hindsight-rest.mjs +45 -0
- package/cli/mcp/notebooklm-provider.mjs +225 -0
- package/cli/mcp/pmm-records.mjs +23 -0
- package/cli/mcp/portability.mjs +158 -0
- package/cli/mcp/stdio.mjs +65 -0
- package/cli/mcp/unified-mcp-install.mjs +65 -0
- package/cli/mem0-mcp.mjs +108 -0
- package/cli/notebooklm-mcp.mjs +236 -0
- package/cli/openmem-mcp.mjs +35 -0
- package/cli/openmem.mjs +205 -0
- package/cli/roots.mjs +85 -0
- package/cli/stack.mjs +436 -0
- package/cli/switch-memory-provider.mjs +358 -0
- package/cli/wiring-report.mjs +125 -0
- package/compose.bench.yaml +12 -0
- package/compose.yaml +164 -0
- package/config/coding-agent.json +13 -0
- package/config/hindsight.env.example +24 -0
- package/config/index.yml +54 -0
- package/config/litellm.lemond.yaml +40 -0
- package/config/litellm.ollama.yaml +33 -0
- package/config/notebooklm.yml +33 -0
- package/config/openmem.toml.example +62 -0
- package/docs/OPENMEM.md +23 -0
- package/docs/architecture.md +219 -0
- package/docs/assets/README.md +10 -0
- package/docs/assets/setup-demo.gif +0 -0
- package/docs/coding-agent-hooks.md +51 -0
- package/docs/coding-agent-scopes.md +22 -0
- package/docs/coding-agents.md +51 -0
- package/docs/cognee.md +37 -0
- package/docs/concepts.md +112 -0
- package/docs/hindsight.md +74 -0
- package/docs/index-sources-roadmap.md +38 -0
- package/docs/index-sources.md +39 -0
- package/docs/indexer-sinks.md +122 -0
- package/docs/indexing.md +57 -0
- package/docs/manual-stack-setup.md +74 -0
- package/docs/mcp-install-guide.md +4 -0
- package/docs/mcp-setup.md +101 -0
- package/docs/mem0.md +103 -0
- package/docs/memory-portability.md +73 -0
- package/docs/memory-providers.md +246 -0
- package/docs/model-configuration.md +84 -0
- package/docs/notebooklm.md +83 -0
- package/docs/npx-install.md +170 -0
- package/docs/openmem-config.md +194 -0
- package/docs/provider-wizard-hooks.md +222 -0
- package/docs/quickstart.md +101 -0
- package/docs/setup-wizard.md +116 -0
- package/docs/sources-feeds.md +52 -0
- package/docs/sources-readwise.md +23 -0
- package/docs/sources-zotero.md +22 -0
- package/glama.json +4 -0
- package/indexer/Dockerfile +38 -0
- package/indexer/code_outline.py +146 -0
- package/indexer/feed_sync.py +107 -0
- package/indexer/index_config.py +290 -0
- package/indexer/pytest.ini +5 -0
- package/indexer/sinks/__init__.py +148 -0
- package/indexer/sinks/_http.py +44 -0
- package/indexer/sinks/cognee.py +329 -0
- package/indexer/sinks/hindsight.py +107 -0
- package/indexer/sinks/mem0.py +247 -0
- package/indexer/watch.py +240 -0
- package/package.json +95 -4
- package/providers/_template/compose.yaml +52 -0
- package/providers/cognee/OPERATOR.md +82 -0
- package/providers/cognee/compose.yaml +89 -0
- package/providers/cognee/lib/clack.mjs +157 -0
- package/providers/cognee/lib/coding-agent.mjs +26 -0
- package/providers/cognee/lib/cognee-mcp-install.mjs +153 -0
- package/providers/cognee/lib/config.mjs +98 -0
- package/providers/cognee/lib/env-file.mjs +194 -0
- package/providers/cognee/lib/env-setup.mjs +83 -0
- package/providers/cognee/lib/llm-env.mjs +382 -0
- package/providers/cognee/lib/ports.mjs +25 -0
- package/providers/cognee/lib/smoke.mjs +246 -0
- package/providers/cognee/lib/stack-env.mjs +134 -0
- package/providers/cognee/lib/state.mjs +101 -0
- package/providers/cognee/lib/summary.mjs +56 -0
- package/providers/cognee/lib/validate.mjs +97 -0
- package/providers/cognee/manifest.json +56 -0
- package/providers/cognee/wizard.mjs +134 -0
- package/providers/graphiti/manifest.json +15 -0
- package/providers/hindsight/lib/clack.mjs +245 -0
- package/providers/hindsight/lib/coding-agent.mjs +204 -0
- package/providers/hindsight/lib/config.mjs +73 -0
- package/providers/hindsight/lib/env-setup.mjs +71 -0
- package/providers/hindsight/lib/litellm-routes.mjs +187 -0
- package/providers/hindsight/lib/model-backend.mjs +214 -0
- package/providers/hindsight/lib/notebooklm-mcp-install.mjs +127 -0
- package/providers/hindsight/lib/ollama-compose.mjs +51 -0
- package/providers/hindsight/lib/ports.mjs +30 -0
- package/providers/hindsight/lib/probe.mjs +33 -0
- package/providers/hindsight/lib/stack-env.mjs +132 -0
- package/providers/hindsight/lib/state.mjs +75 -0
- package/providers/hindsight/lib/summary.mjs +43 -0
- package/providers/hindsight/lib/validate.mjs +75 -0
- package/providers/hindsight/manifest.json +58 -0
- package/providers/hindsight/wizard.mjs +149 -0
- package/providers/letta/manifest.json +29 -0
- package/providers/mem0/OPERATOR.md +124 -0
- package/providers/mem0/compose.yaml +57 -0
- package/providers/mem0/lib/coding-agent.mjs +58 -0
- package/providers/mem0/lib/config.mjs +45 -0
- package/providers/mem0/lib/configure.mjs +135 -0
- package/providers/mem0/lib/dotenv.mjs +71 -0
- package/providers/mem0/lib/env-file.mjs +110 -0
- package/providers/mem0/lib/http.mjs +69 -0
- package/providers/mem0/lib/mcp-install.mjs +165 -0
- package/providers/mem0/lib/secret.mjs +30 -0
- package/providers/mem0/lib/smoke.mjs +179 -0
- package/providers/mem0/lib/stack-env.mjs +216 -0
- package/providers/mem0/lib/state.mjs +67 -0
- package/providers/mem0/lib/summary.mjs +60 -0
- package/providers/mem0/lib/url.mjs +106 -0
- package/providers/mem0/lib/validate.mjs +158 -0
- package/providers/mem0/manifest.json +47 -0
- package/providers/mem0/wizard.mjs +133 -0
- package/providers/zep/manifest.json +29 -0
- package/scripts/check-pack.mjs +218 -0
- package/scripts/gen-masks.mjs +83 -0
- package/scripts/gen-masks.sh +4 -0
- package/scripts/promotion-provider-smoke.mjs +34 -0
- package/scripts/run-setup.sh +30 -0
- package/scripts/setup.mjs +94 -0
- package/scripts/setup.sh +5 -0
- package/scripts/spin-up.sh +45 -0
- package/scripts/wizard.sh +5 -0
- package/server.json +34 -0
- package/skills/openmem/SKILL.md +29 -0
- package/sync/Dockerfile +27 -0
- package/sync/memory_backend.py +87 -0
- package/sync/notebooklm_sync.py +664 -0
- package/wizard/bin/pm.mjs +97 -0
- package/wizard/bin/setup.mjs +33 -0
- package/wizard/lib/compose-version.mjs +32 -0
- package/wizard/lib/compose.mjs +70 -0
- package/wizard/lib/curl.mjs +103 -0
- package/wizard/lib/docker-build.mjs +148 -0
- package/wizard/lib/exec.mjs +41 -0
- package/wizard/lib/indexer.mjs +6 -0
- package/wizard/lib/load-machine-env.mjs +4 -0
- package/wizard/lib/log.mjs +32 -0
- package/wizard/lib/notebooklm-agent-access.mjs +92 -0
- package/wizard/lib/notebooklm.mjs +214 -0
- package/wizard/lib/parse-args.mjs +87 -0
- package/wizard/lib/paths.mjs +39 -0
- package/wizard/lib/pm-config.mjs +163 -0
- package/wizard/lib/ports.mjs +94 -0
- package/wizard/lib/prompts.mjs +57 -0
- package/wizard/lib/provider-wizard/helpers.mjs +98 -0
- package/wizard/lib/provider-wizard/run-steps.mjs +129 -0
- package/wizard/lib/providers/registry.mjs +262 -0
- package/wizard/lib/run-clack.mjs +108 -0
- package/wizard/lib/run-wizard.mjs +755 -0
- package/wizard/lib/validate-setup.mjs +62 -0
- package/wizard/lib/wizard-action.mjs +34 -0
- 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)
|