@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.
- package/.agents/skills/openmem/SKILL.md +34 -0
- package/LICENSE +190 -0
- package/README.md +140 -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 +34 -0
- package/cli/coding-agent/context-store.mjs +81 -0
- package/cli/coding-agent/context-trim.mjs +897 -0
- package/cli/coding-agent/file-io.mjs +72 -0
- package/cli/coding-agent/hook-harnesses.mjs +88 -0
- package/cli/coding-agent/hook-runner.mjs +284 -0
- package/cli/coding-agent/install-access.mjs +119 -0
- package/cli/coding-agent/install-context-proxy.mjs +333 -0
- package/cli/coding-agent/install-rtk.mjs +60 -0
- package/cli/coding-agent/install-shim.mjs +201 -0
- package/cli/coding-agent/opencode-config.mjs +107 -0
- package/cli/coding-agent/opencode-plugin.mjs +132 -0
- package/cli/coding-agent/opencode2-plugin/index.js +299 -0
- package/cli/coding-agent/recall-frame.mjs +29 -0
- package/cli/coding-agent/remove-upstream-access.mjs +122 -0
- package/cli/coding-agent/runtime.mjs +40 -0
- package/cli/coding-agent/session-db.mjs +626 -0
- package/cli/coding-agent/session-facade.mjs +86 -0
- package/cli/coding-agent/toml-edit.mjs +161 -0
- package/cli/cognee-mcp.mjs +260 -0
- package/cli/context-management.mjs +254 -0
- package/cli/feed-sync.mjs +27 -0
- package/cli/install-coding-agent.mjs +298 -0
- package/cli/install-memory-mcp.mjs +426 -0
- package/cli/is-main.mjs +18 -0
- package/cli/mcp/context-sql-runner.mjs +58 -0
- package/cli/mcp/context-store.mjs +670 -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/knowledge.mjs +333 -0
- package/cli/mcp/llm-synthesize.mjs +50 -0
- package/cli/mcp/mem0-scope-id.mjs +22 -0
- package/cli/mcp/memory-facade.mjs +521 -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 +38 -0
- package/cli/openmem.mjs +222 -0
- package/cli/roots.mjs +85 -0
- package/cli/schedule.mjs +176 -0
- package/cli/stack.mjs +438 -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 +227 -0
- package/config/coding-agent.json +13 -0
- package/config/hindsight.env.example +26 -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 +124 -0
- package/docs/OPENMEM.md +23 -0
- package/docs/architecture.md +245 -0
- package/docs/assets/README.md +10 -0
- package/docs/assets/setup-demo.gif +0 -0
- package/docs/coding-agent-hooks.md +75 -0
- package/docs/coding-agent-scopes.md +22 -0
- package/docs/coding-agents.md +71 -0
- package/docs/cognee.md +37 -0
- package/docs/compatibility.md +80 -0
- package/docs/concepts.md +135 -0
- package/docs/context-management.md +255 -0
- package/docs/context-proxy.md +115 -0
- package/docs/context-research.md +215 -0
- package/docs/context-store.md +277 -0
- package/docs/hindsight.md +77 -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 +104 -0
- package/docs/mem0.md +141 -0
- package/docs/memory-portability.md +73 -0
- package/docs/memory-providers.md +246 -0
- package/docs/meridian-litellm.md +198 -0
- package/docs/model-configuration.md +106 -0
- package/docs/notebooklm.md +83 -0
- package/docs/npx-install.md +172 -0
- package/docs/openmem-config.md +194 -0
- package/docs/provider-wizard-hooks.md +222 -0
- package/docs/quickstart.md +107 -0
- package/docs/setup-wizard.md +117 -0
- package/docs/sources-feeds.md +39 -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 +96 -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 +347 -0
- package/providers/hindsight/lib/coding-agent.mjs +204 -0
- package/providers/hindsight/lib/config.mjs +79 -0
- package/providers/hindsight/lib/env-setup.mjs +72 -0
- package/providers/hindsight/lib/litellm-routes.mjs +196 -0
- package/providers/hindsight/lib/meridian.mjs +307 -0
- package/providers/hindsight/lib/model-backend.mjs +243 -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 +54 -0
- package/providers/hindsight/lib/stack-env.mjs +125 -0
- package/providers/hindsight/lib/state.mjs +80 -0
- package/providers/hindsight/lib/summary.mjs +57 -0
- package/providers/hindsight/lib/validate.mjs +81 -0
- package/providers/hindsight/manifest.json +58 -0
- package/providers/hindsight/wizard.mjs +150 -0
- package/providers/letta/manifest.json +29 -0
- package/providers/mem0/OPERATOR.md +143 -0
- package/providers/mem0/compose.yaml +79 -0
- package/providers/mem0/lib/coding-agent.mjs +58 -0
- package/providers/mem0/lib/config.mjs +45 -0
- package/providers/mem0/lib/configure.mjs +201 -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/litellm.mjs +457 -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 +255 -0
- package/providers/mem0/lib/state.mjs +67 -0
- package/providers/mem0/lib/summary.mjs +88 -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 +146 -0
- package/providers/zep/manifest.json +29 -0
- package/proxy/config.yaml +33 -0
- package/proxy/openmem_context/__init__.py +6 -0
- package/proxy/openmem_context/adapters.py +504 -0
- package/proxy/openmem_context/compress.py +53 -0
- package/proxy/openmem_context/core.py +606 -0
- package/proxy/openmem_context/embed.py +212 -0
- package/proxy/openmem_context/recall.py +49 -0
- package/proxy/openmem_context/session.py +70 -0
- package/proxy/openmem_context/sessiondb.py +276 -0
- package/proxy/openmem_context/settings.py +134 -0
- package/proxy/openmem_context/store.py +129 -0
- package/proxy/openmem_context/tokens.py +123 -0
- package/proxy/openmem_context/ttl.py +78 -0
- package/proxy/openmem_context_hook.py +34 -0
- package/scripts/check-pack.mjs +219 -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 +107 -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/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/context-step.mjs +140 -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/litellm-env.mjs +55 -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 +371 -0
- package/wizard/lib/ports.mjs +94 -0
- package/wizard/lib/prompts.mjs +85 -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 +750 -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,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)
|