atomicmemory-langflow 0.1.17__py3-none-any.whl

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.
@@ -0,0 +1,30 @@
1
+ """AtomicMemory custom components for Langflow.
2
+
3
+ Importing this package does NOT import Langflow (`lfx`). Component classes are
4
+ resolved lazily via ``__getattr__`` so the lfx-free helper modules
5
+ (``_scope``/``_messages``/``_sdk``/``_chat_history``) stay unit-testable without
6
+ the Langflow host installed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from importlib import import_module
12
+ from typing import Any
13
+
14
+ __version__ = "0.1.0"
15
+
16
+ _EXPORTS = {
17
+ "AtomicMemoryChatMemoryComponent": "chat_memory",
18
+ "AtomicMemorySearchContextComponent": "search_context",
19
+ "AtomicMemoryStoreMessageComponent": "store_message",
20
+ "AtomicMemoryDeleteComponent": "delete",
21
+ }
22
+
23
+ __all__ = list(_EXPORTS)
24
+
25
+
26
+ def __getattr__(name: str) -> Any:
27
+ module = _EXPORTS.get(name)
28
+ if module is None:
29
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
30
+ return getattr(import_module(f".{module}", __name__), name)
@@ -0,0 +1,60 @@
1
+ """Read-only LangChain chat history backed by AtomicMemory (lfx-free)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from langchain_core.chat_history import BaseChatMessageHistory
9
+ from langchain_core.messages import BaseMessage
10
+
11
+ from ._messages import memory_to_lc_message
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class AtomicMemoryChatMessageHistory(BaseChatMessageHistory):
17
+ """Surfaces a scope's memories as chat history. Writes are no-ops here —
18
+ use the Store Message component. LangChain provides the async surface
19
+ (aget_messages/aadd_messages) by delegating to these sync methods.
20
+ """
21
+
22
+ def __init__(self, *, bridge: Any, scope: dict, limit: int, fail_open: bool = False) -> None:
23
+ self._bridge = bridge
24
+ self._scope = scope
25
+ self._limit = limit
26
+ self._fail_open = fail_open
27
+ self._warned = False
28
+
29
+ @property
30
+ def messages(self) -> list[BaseMessage]:
31
+ try:
32
+ page = self._bridge.list_memories(scope=self._scope, limit=self._limit)
33
+ except Exception as exc:
34
+ # Fail closed by default: surface "memory unavailable" rather than
35
+ # silently pretending the user has no memory. Opt into soft failure
36
+ # (empty history) with fail_open=True.
37
+ if self._fail_open:
38
+ logger.warning(
39
+ "AtomicMemory history read failed; returning empty history (fail_open): %s", exc
40
+ )
41
+ return []
42
+ raise RuntimeError(f"AtomicMemory history read failed: {exc}") from exc
43
+ memories = list(getattr(page, "memories", []))
44
+ memories.reverse() # newest-first -> chronological
45
+ return [memory_to_lc_message(m) for m in memories]
46
+
47
+ def add_messages(self, messages: list[BaseMessage]) -> None:
48
+ if not self._warned:
49
+ logger.warning(
50
+ "AtomicMemory Chat Memory is read-only; writes here are ignored. "
51
+ "Use the 'AtomicMemory Store Message' component to persist memory."
52
+ )
53
+ self._warned = True
54
+
55
+ def add_message(self, message: BaseMessage) -> None:
56
+ self.add_messages([message])
57
+
58
+ def clear(self) -> None:
59
+ # Read-only; erasure is via the AtomicMemory Delete component.
60
+ return None
@@ -0,0 +1,51 @@
1
+ """Mixin shared by the AtomicMemory components (lfx-free; only reads attrs).
2
+
3
+ Inputs are named ``memory_user_id``/``memory_session_id`` (NOT ``user_id``) to
4
+ avoid colliding with Langflow's base ``Component.user_id`` property, which holds
5
+ the authenticated run user we fall back to.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from ._scope import build_scope
13
+ from ._sdk import AtomicMemoryBridge
14
+
15
+
16
+ class AtomicMemoryComponentMixin:
17
+ def _resolve_user_id(self) -> str:
18
+ explicit = (getattr(self, "memory_user_id", "") or "")
19
+ explicit = str(explicit).strip()
20
+ if explicit:
21
+ return explicit
22
+ ctx = getattr(self, "user_id", None) # base Component.user_id (run context)
23
+ return str(ctx).strip() if ctx else ""
24
+
25
+ def _resolve_session_id(self) -> str | None:
26
+ explicit = (getattr(self, "memory_session_id", "") or "")
27
+ explicit = str(explicit).strip()
28
+ if explicit:
29
+ return explicit
30
+ graph = getattr(self, "graph", None)
31
+ sid = getattr(graph, "session_id", None) if graph is not None else None
32
+ return str(sid).strip() if sid else None
33
+
34
+ def _build_scope(self, *, include_session: bool = True) -> dict:
35
+ # namespace is intentionally not plumbed in Phase 1 (provider honors it
36
+ # only on search/package, not ingest/list/delete). See _inputs.scope_inputs.
37
+ # include_session=False yields a user-only scope for cross-session recall:
38
+ # Core hard-filters search/list by session, so retrieval meant to span
39
+ # sessions must omit the thread.
40
+ return build_scope(
41
+ self._resolve_user_id(),
42
+ session_id=self._resolve_session_id() if include_session else None,
43
+ )
44
+
45
+ def _build_bridge(self) -> AtomicMemoryBridge:
46
+ return AtomicMemoryBridge(
47
+ provider=getattr(self, "provider", "atomicmemory"),
48
+ api_url=getattr(self, "api_url", None),
49
+ api_key=getattr(self, "api_key", None),
50
+ provider_config=dict(getattr(self, "provider_config", {}) or {}),
51
+ )
@@ -0,0 +1,72 @@
1
+ """Shared Langflow input builders (imports lfx). Each call returns fresh Input
2
+ instances so components do not share mutable input objects."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from lfx.inputs.inputs import (
7
+ DictInput,
8
+ DropdownInput,
9
+ MessageTextInput,
10
+ SecretStrInput,
11
+ )
12
+
13
+ from ._sdk import DEFAULT_API_URL
14
+
15
+
16
+ def connection_inputs() -> list:
17
+ return [
18
+ DropdownInput(
19
+ name="provider",
20
+ display_name="Provider",
21
+ options=["atomicmemory"],
22
+ value="atomicmemory",
23
+ advanced=True,
24
+ info="Memory provider. Phase 1 supports atomicmemory.",
25
+ ),
26
+ MessageTextInput(
27
+ name="api_url",
28
+ display_name="API URL",
29
+ value=DEFAULT_API_URL,
30
+ advanced=True,
31
+ info="AtomicMemory Core base URL.",
32
+ ),
33
+ SecretStrInput(
34
+ name="api_key",
35
+ display_name="API Key",
36
+ value="",
37
+ required=False,
38
+ advanced=True,
39
+ info="API key (optional for local Core). Never put secrets in Provider Config.",
40
+ ),
41
+ DictInput(
42
+ name="provider_config",
43
+ display_name="Provider Config",
44
+ value={},
45
+ advanced=True,
46
+ info="Advanced SDK provider config. Must not contain secrets.",
47
+ ),
48
+ ]
49
+
50
+
51
+ def scope_inputs(*, include_session: bool = True) -> list:
52
+ # NOTE: `namespace` is intentionally NOT exposed in Phase 1. The AtomicMemory
53
+ # Python provider only applies namespace on search/package — ingest/list/delete
54
+ # ignore it — so exposing it would silently break scoping (store/delete would
55
+ # not be namespace-isolated). Re-add only after end-to-end namespace support.
56
+ items = [
57
+ MessageTextInput(
58
+ name="memory_user_id",
59
+ display_name="User ID",
60
+ info="Memory scope. Defaults to the Langflow run user when left blank.",
61
+ ),
62
+ ]
63
+ if include_session:
64
+ items.append(
65
+ MessageTextInput(
66
+ name="memory_session_id",
67
+ display_name="Session ID",
68
+ advanced=True,
69
+ info="Session/thread scope. Defaults to the flow session when blank.",
70
+ )
71
+ )
72
+ return items
@@ -0,0 +1,68 @@
1
+ """Convert between Langflow/LangChain senders and SDK roles, and map stored
2
+ memories to LangChain messages (lfx-free)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any
7
+
8
+
9
+ def coerce_text(value: Any) -> str:
10
+ """Extract plain text from an input that may be a Langflow/LangChain Message.
11
+
12
+ A MessageTextInput fed from another component's Message output can arrive as
13
+ a Message object, whose ``str()`` is its JSON serialization (``{"text": ...}``),
14
+ not the text. Stringifying that as a search query or ingest content corrupts
15
+ it. Prefer ``.text`` when present; otherwise fall back to ``str()``.
16
+ """
17
+ if value is None:
18
+ return ""
19
+ text = getattr(value, "text", None)
20
+ if isinstance(text, str):
21
+ return text
22
+ return str(value)
23
+
24
+
25
+ # Langflow sender constants ("User"/"Machine"/"System"/"Tool") + LangChain
26
+ # message types ("human"/"ai"/"system"/"tool") -> SDK role.
27
+ _SENDER_TO_ROLE = {
28
+ "user": "user",
29
+ "human": "user",
30
+ "assistant": "assistant",
31
+ "ai": "assistant",
32
+ "machine": "assistant",
33
+ "system": "system",
34
+ "tool": "tool",
35
+ }
36
+
37
+
38
+ def sender_to_role(sender: Any) -> str:
39
+ """Total map to an SDK role (`user|assistant|system|tool`); unknown -> `user`."""
40
+ if sender is None:
41
+ return "user"
42
+ return _SENDER_TO_ROLE.get(str(sender).strip().lower(), "user")
43
+
44
+
45
+ def memory_to_lc_message(memory: Any):
46
+ """Map a stored Memory to a LangChain message.
47
+
48
+ Role is NOT generally preserved: the AtomicMemory provider flattens
49
+ messages-mode ingest into a transcript and extracts semantic memories, so
50
+ most recalled memories have no ``role`` metadata and come back as a
51
+ ``[memory] …`` HumanMessage. The ``role == "assistant"`` check below is
52
+ best-effort for the rare case a provider surfaces role metadata.
53
+
54
+ SECURITY: retrieved memory is user-influenced; never return a SystemMessage
55
+ (which would grant system authority — a prompt-injection vector). Everything
56
+ that isn't an explicit assistant memory is a HumanMessage tagged ``[memory]``
57
+ so downstream prompts can see it is recalled context.
58
+ """
59
+ from langchain_core.messages import AIMessage, HumanMessage
60
+
61
+ content = getattr(memory, "content", "") or ""
62
+ role = None
63
+ meta = getattr(memory, "metadata", None)
64
+ if isinstance(meta, dict):
65
+ role = meta.get("role")
66
+ if role == "assistant":
67
+ return AIMessage(content=content)
68
+ return HumanMessage(content=f"[memory] {content}")
@@ -0,0 +1,40 @@
1
+ """Map Langflow inputs to an AtomicMemory SDK scope dict (lfx-free, SDK-free)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def _clean(value: Any) -> str | None:
9
+ if value is None:
10
+ return None
11
+ text = str(value).strip()
12
+ return text or None
13
+
14
+
15
+ def build_scope(
16
+ user_id: Any,
17
+ *,
18
+ session_id: Any = None,
19
+ namespace: Any = None,
20
+ agent_id: Any = None,
21
+ ) -> dict[str, str]:
22
+ """Build an SDK scope dict. ``user`` is required (Core enforces it).
23
+
24
+ Langflow session -> ``thread``; namespace -> ``namespace``; agent -> ``agent``.
25
+ Optional fields are omitted when blank.
26
+ """
27
+ user = _clean(user_id)
28
+ if not user:
29
+ raise ValueError("AtomicMemory requires a non-empty user_id.")
30
+ scope: dict[str, str] = {"user": user}
31
+ thread = _clean(session_id)
32
+ if thread:
33
+ scope["thread"] = thread
34
+ ns = _clean(namespace)
35
+ if ns:
36
+ scope["namespace"] = ns
37
+ agent = _clean(agent_id)
38
+ if agent:
39
+ scope["agent"] = agent
40
+ return scope
@@ -0,0 +1,234 @@
1
+ """SDK boundary: the single place that touches the `atomicmemory` SDK.
2
+
3
+ lfx-free. Components call this bridge; the bridge passes plain dicts to the SDK
4
+ client (the client coerces them into validated Pydantic requests). A
5
+ ``client_factory`` hook lets tests inject a fake client.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import os
12
+ from contextlib import contextmanager
13
+ from typing import Any, Callable, Iterator
14
+ from urllib.parse import urlparse
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ DEFAULT_API_URL = "http://localhost:17350"
19
+ _LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
20
+
21
+ # Sensitivity class for extraction-mode ingests is never inferred. `mode="messages"`
22
+ # runs core-side LLM extraction, which persists the *raw* transcript in the audit
23
+ # episode (not a derived summary), so the bridge omits content_class by default: a
24
+ # core running the default RAW_CONTENT_POLICY=reject then redacts that raw transcript
25
+ # while still extracting searchable memories. Callers stamp "summary"/"redacted"
26
+ # explicitly only when the content is genuinely distilled or has sensitive spans removed.
27
+
28
+ # Phase 1 supports only the atomicmemory provider end-to-end.
29
+ SUPPORTED_PROVIDERS = frozenset({"atomicmemory"})
30
+
31
+ # provider_config is ALLOWLIST-only: just harmless SDK tuning keys. Anything else —
32
+ # including any secret/connection-shaped key (accessToken, clientSecret, headers,
33
+ # authorization, …) — is rejected. Secrets and the URL belong in the dedicated
34
+ # 'API Key' / 'API URL' fields, never in the plaintext-persisted provider_config.
35
+ _ALLOWED_CONFIG_KEYS = frozenset(
36
+ {"timeoutseconds", "timeout_seconds", "apiversion", "api_version"}
37
+ )
38
+
39
+ # Operator-controlled (env, NOT flow-author) allowance for a non-local api_url.
40
+ _ALLOW_REMOTE_ENV = "ATOMICMEMORY_LANGFLOW_ALLOW_REMOTE"
41
+ _ALLOWED_HOSTS_ENV = "ATOMICMEMORY_LANGFLOW_ALLOWED_HOSTS"
42
+
43
+
44
+ def sdk_is_available() -> bool:
45
+ try:
46
+ import atomicmemory # noqa: F401
47
+ except Exception: # pragma: no cover - import guard
48
+ return False
49
+ return True
50
+
51
+
52
+ def _require_sdk():
53
+ try:
54
+ import atomicmemory
55
+ except ImportError as exc: # pragma: no cover - exercised via monkeypatch
56
+ raise RuntimeError(
57
+ "The 'atomicmemory' SDK is required for AtomicMemory components. "
58
+ "Install it with: pip install atomicmemory"
59
+ ) from exc
60
+ return atomicmemory
61
+
62
+
63
+ def _env_truthy(value: Any) -> bool:
64
+ return str(value).strip().lower() in {"1", "true", "yes", "on"} if value else False
65
+
66
+
67
+ def _remote_host_allowed(host: str) -> bool:
68
+ if _env_truthy(os.environ.get(_ALLOW_REMOTE_ENV)):
69
+ return True
70
+ allowed = os.environ.get(_ALLOWED_HOSTS_ENV, "")
71
+ return host in {h.strip().lower() for h in allowed.split(",") if h.strip()}
72
+
73
+
74
+ def validate_api_url(api_url: Any) -> str:
75
+ url = (str(api_url).strip() if api_url else "") or DEFAULT_API_URL
76
+ parsed = urlparse(url)
77
+ if parsed.scheme not in ("http", "https"):
78
+ raise ValueError(f"api_url must be http or https, got: {url!r}")
79
+ host = (parsed.hostname or "").lower()
80
+ if host not in _LOCAL_HOSTS and not _remote_host_allowed(host):
81
+ # Restrict non-local hosts unless the operator opts in via env. NOTE: this is
82
+ # not full SSRF protection — loopback (localhost ports) is always allowed; see
83
+ # the README security section for the shared/cloud caveat.
84
+ raise ValueError(
85
+ f"api_url host {host!r} is not local and not allowed. To use a remote "
86
+ f"AtomicMemory Core, the operator must set {_ALLOW_REMOTE_ENV}=1 or list "
87
+ f"the host in {_ALLOWED_HOSTS_ENV} (comma-separated)."
88
+ )
89
+ return url
90
+
91
+
92
+ def _normalize_key(key: Any) -> str:
93
+ return str(key).strip().lower().replace("-", "_").replace(" ", "_")
94
+
95
+
96
+ def validate_provider(provider: Any) -> str:
97
+ name = (str(provider).strip() if provider else "") or "atomicmemory"
98
+ if name not in SUPPORTED_PROVIDERS:
99
+ raise ValueError(
100
+ f"Unsupported provider {name!r}. Phase 1 supports only: "
101
+ f"{', '.join(sorted(SUPPORTED_PROVIDERS))}."
102
+ )
103
+ return name
104
+
105
+
106
+ def validate_provider_config(provider_config: Any) -> dict[str, Any]:
107
+ """Allowlist-only: accept just known tuning keys; reject everything else
108
+ (URLs, keys, and any secret-shaped key like accessToken/clientSecret)."""
109
+ cfg = dict(provider_config or {})
110
+ for key in cfg:
111
+ if _normalize_key(key) not in _ALLOWED_CONFIG_KEYS:
112
+ raise ValueError(
113
+ f"provider_config key {key!r} is not allowed. Phase 1 accepts only "
114
+ "tuning keys (timeoutSeconds, apiVersion); set the API URL/Key via the "
115
+ "component's 'API URL' and 'API Key' (secret) fields, not provider_config."
116
+ )
117
+ return cfg
118
+
119
+
120
+ class AtomicMemoryBridge:
121
+ """Thin, sync boundary over the AtomicMemory SDK MemoryClient.
122
+
123
+ A client is constructed + initialized + closed per operation (cheap at
124
+ canvas latencies; avoids connection leaks and cross-run state). Requests are
125
+ plain dicts; the SDK coerces/validates them.
126
+ """
127
+
128
+ def __init__(
129
+ self,
130
+ *,
131
+ provider: str = "atomicmemory",
132
+ api_url: Any = None,
133
+ api_key: Any = None,
134
+ provider_config: Any = None,
135
+ client_factory: Callable[[dict, str], Any] | None = None,
136
+ ) -> None:
137
+ self._provider = validate_provider(provider)
138
+ self._api_url = validate_api_url(api_url)
139
+ self._api_key = (str(api_key).strip() or None) if api_key else None
140
+ self._provider_config = validate_provider_config(provider_config)
141
+ self._client_factory = client_factory
142
+
143
+ def _provider_settings(self) -> dict[str, Any]:
144
+ # provider_config first, then the validated connection fields LAST so they
145
+ # can never be overridden (defense in depth alongside validate_provider_config).
146
+ settings: dict[str, Any] = {**self._provider_config, "apiUrl": self._api_url}
147
+ if self._api_key:
148
+ settings["apiKey"] = self._api_key
149
+ return settings
150
+
151
+ @contextmanager
152
+ def _client(self) -> Iterator[Any]:
153
+ if self._client_factory is not None:
154
+ client = self._client_factory({self._provider: self._provider_settings()}, self._provider)
155
+ else:
156
+ am = _require_sdk()
157
+ client = am.MemoryClient(
158
+ providers={self._provider: self._provider_settings()},
159
+ default_provider=self._provider,
160
+ )
161
+ try:
162
+ client.initialize()
163
+ yield client
164
+ finally:
165
+ client.close()
166
+
167
+ def capabilities(self):
168
+ with self._client() as client:
169
+ return client.capabilities()
170
+
171
+ def ingest_messages(
172
+ self,
173
+ *,
174
+ scope: dict,
175
+ messages: list[dict],
176
+ metadata: dict | None = None,
177
+ content_class: str | None = None,
178
+ ):
179
+ # Never infer a class: when unset, omit it so a core running
180
+ # RAW_CONTENT_POLICY=reject redacts the raw transcript from the audit
181
+ # episode (extraction still runs) instead of the plugin mislabeling it.
182
+ body = {
183
+ "mode": "messages",
184
+ "scope": scope,
185
+ "messages": messages,
186
+ "provenance": {"source": "langflow"},
187
+ "metadata": metadata or {},
188
+ }
189
+ if content_class:
190
+ body["content_class"] = content_class
191
+ with self._client() as client:
192
+ return client.ingest(body)
193
+
194
+ def list_memories(self, *, scope: dict, limit: int):
195
+ with self._client() as client:
196
+ return client.list({"scope": scope, "limit": limit})
197
+
198
+ def search(self, *, scope: dict, query: str, limit: int):
199
+ with self._client() as client:
200
+ return client.search({"scope": scope, "query": query, "limit": limit})
201
+
202
+ def package(self, *, scope: dict, query: str, limit: int, token_budget: int | None = None):
203
+ with self._client() as client:
204
+ req: dict[str, Any] = {"scope": scope, "query": query, "limit": limit}
205
+ if token_budget is not None:
206
+ req["token_budget"] = token_budget
207
+ return client.package(req)
208
+
209
+ def delete_scope(self, *, scope: dict, page_size: int = 100) -> dict[str, int]:
210
+ """Best-effort scope erasure: page list() then delete() each id.
211
+
212
+ The SDK has no native scope-wipe; this is best-effort over SDK-visible
213
+ memories. Ids are collected first (no mutate-while-paginating).
214
+ """
215
+ with self._client() as client:
216
+ ids: list[str] = []
217
+ cursor: str | None = None
218
+ while True:
219
+ req: dict[str, Any] = {"scope": scope, "limit": page_size}
220
+ if cursor:
221
+ req["cursor"] = cursor
222
+ page = client.list(req)
223
+ ids.extend(m.id for m in page.memories)
224
+ cursor = getattr(page, "cursor", None)
225
+ if not cursor or not page.memories:
226
+ break
227
+ deleted = failed = 0
228
+ for mid in ids:
229
+ try:
230
+ client.delete({"id": mid, "scope": scope})
231
+ deleted += 1
232
+ except Exception: # noqa: BLE001 - best-effort; count failures
233
+ failed += 1
234
+ return {"deleted": deleted, "failed": failed, "found": len(ids)}
@@ -0,0 +1,55 @@
1
+ """AtomicMemory Chat Memory — read-only Langflow Message History backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from lfx.base.memory.model import LCChatMemoryComponent
6
+ from lfx.field_typing.constants import Memory
7
+ from lfx.inputs.inputs import BoolInput, IntInput
8
+
9
+ from ._chat_history import AtomicMemoryChatMessageHistory
10
+ from ._component_base import AtomicMemoryComponentMixin
11
+ from ._inputs import connection_inputs, scope_inputs
12
+
13
+ MAX_HISTORY_LIMIT = 100
14
+
15
+
16
+ class AtomicMemoryChatMemoryComponent(AtomicMemoryComponentMixin, LCChatMemoryComponent):
17
+ display_name = "Chat Memory (AtomicMemory)"
18
+ description = (
19
+ "Read-only chat history backed by AtomicMemory (semantic memory for the "
20
+ "user/session). Persist memory with the AtomicMemory Store Message component."
21
+ )
22
+ name = "AtomicMemoryChatMemory"
23
+ icon = "messages-square"
24
+
25
+ inputs = [
26
+ *connection_inputs(),
27
+ *scope_inputs(),
28
+ IntInput(
29
+ name="limit",
30
+ display_name="Max memories",
31
+ value=10,
32
+ info=f"Maximum memories to surface as history (capped at {MAX_HISTORY_LIMIT}).",
33
+ ),
34
+ BoolInput(
35
+ name="fail_open",
36
+ display_name="Fail open on error",
37
+ value=False,
38
+ advanced=True,
39
+ info="If the memory backend is unreachable: when false (default), raise a "
40
+ "clear error; when true, return empty history instead.",
41
+ ),
42
+ ]
43
+
44
+ def build_message_history(self) -> Memory:
45
+ scope = self._build_scope()
46
+ bridge = self._build_bridge()
47
+ try:
48
+ raw = int(self.limit)
49
+ except (TypeError, ValueError):
50
+ raw = 10
51
+ limit = max(1, min(raw, MAX_HISTORY_LIMIT))
52
+ self.status = f"AtomicMemory history · user={scope['user']} · limit={limit}"
53
+ return AtomicMemoryChatMessageHistory(
54
+ bridge=bridge, scope=scope, limit=limit, fail_open=bool(self.fail_open),
55
+ )
@@ -0,0 +1,56 @@
1
+ """AtomicMemory Delete Memories in Scope — best-effort erasure (right-to-erasure).
2
+
3
+ Deletes the SDK-visible memories in a scope (paged list -> delete each). Not a
4
+ native atomic Core scope-wipe. Confirmation-gated.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from lfx.custom.custom_component.component import Component
10
+ from lfx.inputs.inputs import BoolInput
11
+ from lfx.schema.message import Message
12
+ from lfx.template.field.base import Output
13
+
14
+ from ._component_base import AtomicMemoryComponentMixin
15
+ from ._inputs import connection_inputs, scope_inputs
16
+
17
+
18
+ class AtomicMemoryDeleteComponent(AtomicMemoryComponentMixin, Component):
19
+ display_name = "Delete Memories in Scope (AtomicMemory)"
20
+ description = (
21
+ "Delete the SDK-visible memories in a scope (best-effort, not an atomic "
22
+ "Core wipe). Requires explicit confirmation."
23
+ )
24
+ name = "AtomicMemoryDelete"
25
+ icon = "trash"
26
+
27
+ inputs = [
28
+ *connection_inputs(),
29
+ *scope_inputs(),
30
+ BoolInput(
31
+ name="confirm",
32
+ display_name="Confirm",
33
+ value=False,
34
+ info="Must be true to delete. Guards against accidental erasure.",
35
+ ),
36
+ ]
37
+
38
+ outputs = [Output(name="result", display_name="Result", method="delete")]
39
+
40
+ def delete(self) -> Message:
41
+ scope = self._build_scope()
42
+ if not self.confirm:
43
+ text = "Delete skipped: set 'Confirm' to true to erase memories in this scope."
44
+ self.status = "skipped (confirm=false)"
45
+ return Message(text=text, sender="Machine", sender_name="AtomicMemory")
46
+ bridge = self._build_bridge()
47
+ summary = bridge.delete_scope(scope=scope)
48
+ text = (
49
+ f"Deleted {summary['deleted']} of {summary['found']} memories "
50
+ f"(failed {summary['failed']}) for scope {scope}."
51
+ )
52
+ self.status = text
53
+ return Message(
54
+ text=text, sender="Machine", sender_name="AtomicMemory",
55
+ session_metadata={"atomicmemory": summary},
56
+ )
File without changes
@@ -0,0 +1,100 @@
1
+ """AtomicMemory Search Context — query-driven, prompt-ready memory context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from lfx.custom.custom_component.component import Component
8
+ from lfx.inputs.inputs import BoolInput, IntInput, MessageTextInput
9
+ from lfx.schema.message import Message
10
+ from lfx.template.field.base import Output
11
+
12
+ from ._component_base import AtomicMemoryComponentMixin
13
+ from ._inputs import connection_inputs, scope_inputs
14
+ from ._messages import coerce_text
15
+
16
+ DEFAULT_SEARCH_LIMIT = 5
17
+ MAX_SEARCH_LIMIT = 100
18
+
19
+
20
+ def _clamp_limit(value: Any) -> int:
21
+ try:
22
+ limit = int(value)
23
+ except (TypeError, ValueError):
24
+ return DEFAULT_SEARCH_LIMIT
25
+ return max(1, min(limit, MAX_SEARCH_LIMIT))
26
+
27
+
28
+ def _format_results(page: Any) -> str:
29
+ lines = []
30
+ for result in getattr(page, "results", []) or []:
31
+ memory = getattr(result, "memory", None)
32
+ content = getattr(memory, "content", None) or getattr(result, "content", "")
33
+ if content:
34
+ lines.append(f"- {content}")
35
+ return "\n".join(lines) if lines else "(no relevant memories found)"
36
+
37
+
38
+ class AtomicMemorySearchContextComponent(AtomicMemoryComponentMixin, Component):
39
+ display_name = "Search Context (AtomicMemory)"
40
+ description = "Retrieve relevant long-term memory for a query as prompt-ready context."
41
+ name = "AtomicMemorySearchContext"
42
+ icon = "search"
43
+
44
+ inputs = [
45
+ MessageTextInput(name="query", display_name="Query", required=True),
46
+ *connection_inputs(),
47
+ *scope_inputs(),
48
+ IntInput(
49
+ name="limit",
50
+ display_name="Limit",
51
+ value=DEFAULT_SEARCH_LIMIT,
52
+ info=f"Max memories to retrieve (clamped to 1..{MAX_SEARCH_LIMIT}).",
53
+ ),
54
+ BoolInput(
55
+ name="use_packaged_context",
56
+ display_name="Use packaged context",
57
+ value=True,
58
+ info="Use the provider's packaged context. Requires provider support; "
59
+ "turn off for search-only mode.",
60
+ ),
61
+ BoolInput(
62
+ name="scope_to_session",
63
+ display_name="Scope to session",
64
+ value=False,
65
+ advanced=True,
66
+ info="When off (default), recall spans the user's whole memory across "
67
+ "sessions — the point of long-term memory. When on, restrict retrieval to "
68
+ "the current session/thread (Core hard-filters by session).",
69
+ ),
70
+ ]
71
+
72
+ outputs = [Output(name="context", display_name="Context", method="build_context")]
73
+
74
+ def build_context(self) -> Message:
75
+ query = coerce_text(self.query).strip()
76
+ if not query:
77
+ raise ValueError("Search Context requires a non-empty query.")
78
+ # Long-term recall is user-scoped by default (cross-session); opt into
79
+ # session-only retrieval with scope_to_session.
80
+ scope = self._build_scope(include_session=bool(self.scope_to_session))
81
+ bridge = self._build_bridge()
82
+ limit = _clamp_limit(self.limit)
83
+
84
+ if self.use_packaged_context:
85
+ caps = bridge.capabilities()
86
+ if not getattr(getattr(caps, "extensions", None), "package", False):
87
+ raise ValueError(
88
+ "Provider does not support packaged context "
89
+ "(capabilities().extensions.package is false). "
90
+ "Set 'Use packaged context' to false for search-only mode."
91
+ )
92
+ package = bridge.package(scope=scope, query=query, limit=limit)
93
+ text = package.text
94
+ else:
95
+ page = bridge.search(scope=scope, query=query, limit=limit)
96
+ text = _format_results(page)
97
+
98
+ self.status = f"AtomicMemory context · {len(text)} chars"
99
+ # sender is required for Langflow message persistence (MessageResponse.from_message).
100
+ return Message(text=text, sender="Machine", sender_name="AtomicMemory Search Context")
@@ -0,0 +1,81 @@
1
+ """AtomicMemory Store Message — explicitly persist one message into memory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from lfx.custom.custom_component.component import Component
6
+ from lfx.inputs.inputs import DropdownInput, MessageTextInput
7
+ from lfx.schema.message import Message
8
+ from lfx.template.field.base import Output
9
+
10
+ from ._component_base import AtomicMemoryComponentMixin
11
+ from ._inputs import connection_inputs, scope_inputs
12
+ from ._messages import coerce_text, sender_to_role
13
+
14
+ MAX_CONTENT_CHARS = 100_000 # Core rejects conversations beyond this.
15
+
16
+
17
+ class AtomicMemoryStoreMessageComponent(AtomicMemoryComponentMixin, Component):
18
+ display_name = "Store Message (AtomicMemory)"
19
+ description = "Store a message/turn into AtomicMemory (explicit, visible write)."
20
+ name = "AtomicMemoryStoreMessage"
21
+ icon = "save"
22
+
23
+ inputs = [
24
+ MessageTextInput(name="message", display_name="Message", required=True),
25
+ DropdownInput(
26
+ name="sender",
27
+ display_name="Sender",
28
+ options=["User", "Machine", "System", "Tool"],
29
+ value="User",
30
+ ),
31
+ DropdownInput(
32
+ name="content_class",
33
+ display_name="Content Class",
34
+ options=["raw", "summary", "redacted"],
35
+ value="raw",
36
+ info=(
37
+ "How the core treats the stored transcript under "
38
+ "RAW_CONTENT_POLICY=reject. 'raw' (default): the message is extracted "
39
+ "into searchable memories but the raw transcript is omitted from the "
40
+ "audit episode. Choose 'summary' or 'redacted' only when the message is "
41
+ "genuinely distilled or has had sensitive spans removed."
42
+ ),
43
+ ),
44
+ *connection_inputs(),
45
+ *scope_inputs(),
46
+ ]
47
+
48
+ outputs = [Output(name="stored_message", display_name="Stored Message", method="store_message")]
49
+
50
+ def store_message(self) -> Message:
51
+ text = coerce_text(self.message).strip()
52
+ if not text:
53
+ # Fail closed even on API/tweak paths (the UI marks the field required).
54
+ raise ValueError("Store Message requires non-empty message content.")
55
+ if len(text) > MAX_CONTENT_CHARS:
56
+ raise ValueError(
57
+ f"message is {len(text)} chars; AtomicMemory Core limit is {MAX_CONTENT_CHARS}."
58
+ )
59
+ scope = self._build_scope()
60
+ bridge = self._build_bridge()
61
+ role = sender_to_role(self.sender)
62
+ result = bridge.ingest_messages(
63
+ scope=scope,
64
+ messages=[{"role": role, "content": text}],
65
+ metadata={"kind": "turn"},
66
+ content_class=self.content_class or None,
67
+ )
68
+ outcome = {
69
+ "created": len(getattr(result, "created", []) or []),
70
+ "updated": len(getattr(result, "updated", []) or []),
71
+ "unchanged": len(getattr(result, "unchanged", []) or []),
72
+ }
73
+ self.status = (
74
+ f"stored · +{outcome['created']} ~{outcome['updated']} ={outcome['unchanged']}"
75
+ )
76
+ return Message(
77
+ text=text,
78
+ sender=self.sender,
79
+ sender_name="AtomicMemory",
80
+ session_metadata={"atomicmemory": outcome},
81
+ )
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: atomicmemory-langflow
3
+ Version: 0.1.17
4
+ Summary: AtomicMemory custom components for Langflow.
5
+ Author: Atomic Strata
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Repository, https://github.com/atomicstrata/atomicmemory
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: atomicmemory<2.0.0,>=1.1.0
11
+ Requires-Dist: langchain-core<2.0,>=0.3
12
+ Provides-Extra: langflow
13
+ Requires-Dist: langflow<2.0,>=1.6; extra == "langflow"
14
+
15
+ # AtomicMemory components for Langflow
16
+
17
+ Four Langflow custom components backed by the Python `atomicmemory` SDK:
18
+
19
+ They appear in the Langflow component sidebar under the **atomicmemory** category:
20
+
21
+ | Component | Purpose |
22
+ |-----------|---------|
23
+ | **Chat Memory (AtomicMemory)** | Read-only chat history (Message History backend) from a user/session scope. |
24
+ | **Search Context (AtomicMemory)** | Query-driven, prompt-ready memory context, **user-scoped across sessions** by default (packaged or search-only). |
25
+ | **Store Message (AtomicMemory)** | Explicitly persist a message/turn into memory. |
26
+ | **Delete Memories in Scope (AtomicMemory)** | Best-effort erasure of a scope's memories (confirm-gated). |
27
+
28
+ ## Requirements & compatibility
29
+
30
+ - Python ≥ 3.10, `atomicmemory >= 1.0.1`, `langchain-core`.
31
+ - **Langflow is the host** and must be installed in the same environment.
32
+ Tested with Langflow `>=1.6,<2.0` (the components import a few `lfx` internals;
33
+ see the loader smoke test). Newer Langflow majors may move these symbols.
34
+ - A running **AtomicMemory Core** (default `http://localhost:17350`). Core needs
35
+ an LLM/embeddings key for ingest extraction.
36
+
37
+ > **Heads up:** ingest runs synchronous LLM extraction + embedding, so storing a
38
+ > memory can take **seconds (sometimes ~20s)**. Writes are explicit (Store Message)
39
+ > so this latency is visible, not hidden. Chat Memory is **read-only** — it never
40
+ > auto-writes on every turn. If the backend is unreachable, Chat Memory **fails
41
+ > closed** (raises a clear error) by default; set its `Fail open on error` toggle
42
+ > to return empty history instead.
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pip install atomicmemory-langflow # into Langflow's environment
48
+ # copy the component entry files into your Langflow components root:
49
+ npx @atomicmemory/langflow-plugin --target ~/.langflow/components --python <langflow-python>
50
+ # or set the components root via env instead of --target:
51
+ LANGFLOW_COMPONENTS_PATH=~/.langflow/components npx @atomicmemory/langflow-plugin --python <langflow-python>
52
+ ```
53
+ Restart Langflow; the components appear under the **atomicmemory** category.
54
+
55
+ ## Scope, identity & multi-tenant safety
56
+
57
+ Memory is scoped by `user` (required) and optional `session` (thread).
58
+ `User ID` defaults to the **Langflow run user** when blank; an explicit value
59
+ overrides it. Note this is run context, not strong auth — in CLI/anonymous paths
60
+ Langflow may auto-generate an opaque user id.
61
+
62
+ **Search Context recalls user-scoped (across sessions) by default** — long-term
63
+ memory should persist beyond a single conversation, and Core hard-filters
64
+ search/list by session. Set its advanced `Scope to session` toggle to restrict
65
+ retrieval to the current session. Chat Memory (this-conversation history) and
66
+ Store Message remain session-aware.
67
+
68
+ (`namespace` is not exposed in Phase 1: the AtomicMemory Python provider only
69
+ applies it on search/package, not ingest/list/delete, so exposing it would
70
+ silently break store/delete scoping. It returns once the SDK honors it end-to-end.)
71
+
72
+ **Trust boundary:** scope is the only memory boundary, and Langflow lets `user_id`/
73
+ `session_id` be set via flow inputs/tweaks. In shared / multi-tenant / Cloud
74
+ deployments, control who can edit and run flows — a flow author who sets `user_id`
75
+ can read/write that user's memories.
76
+
77
+ ## Security
78
+
79
+ - Put API keys only in the **API Key** (secret) field — never in **Provider Config**
80
+ (it is stored in plaintext in the flow). **Provider Config is allowlist-only**:
81
+ only known tuning keys (`timeoutSeconds`, `apiVersion`) are accepted; everything
82
+ else — URLs, keys, and any secret-shaped key (`accessToken`, `clientSecret`, …) —
83
+ is rejected.
84
+ - **`provider` is validated**: Phase 1 accepts only `atomicmemory`, even via API/tweaks
85
+ (the UI dropdown is not the only guard).
86
+ - **`API URL` is fail-closed for remote hosts.** It must be `http(s)` and resolve to a
87
+ local host by default; pointing memory at a non-local endpoint requires the
88
+ **operator** (not the flow author) to opt in via `ATOMICMEMORY_LANGFLOW_ALLOW_REMOTE=1`
89
+ or `ATOMICMEMORY_LANGFLOW_ALLOWED_HOSTS=host1,host2`. **This is not full SSRF
90
+ protection:** it does not sandbox the loopback interface, so a flow author can still
91
+ reach services bound to the Langflow host's `localhost`/`127.0.0.1` (any port). Treat
92
+ flow authors as trusted, or add network-egress controls, on shared/multi-tenant/cloud
93
+ deployments.
94
+ - Retrieved memory is emitted as ordinary context, never as a system message.
95
+
96
+ ## Provider neutrality
97
+
98
+ `provider` defaults to `atomicmemory` (the only Phase 1 tested provider). The
99
+ architecture is provider-neutral — provider name + `provider_config` flow to the
100
+ SDK — but other providers are not yet listed in the dropdown.
101
+
102
+ ## Testing & known follow-ups
103
+
104
+ Unit tests run without a live backend (`cd plugins/langflow && python -m unittest
105
+ discover -s tests`); the SDK-contract and Langflow-loader tests exercise the real
106
+ `atomicmemory` SDK models and `lfx` template builder when those packages are
107
+ installed.
108
+
109
+ Follow-ups (tracked, not yet in this PR):
110
+ - **End-to-end lane against a real AtomicMemory Core** (Docker + Core + an LLM key):
111
+ Store Message → Search Context → Delete with synthetic data, with the package
112
+ installed into a Langflow-compatible venv. Unit tests use fakes/model coercion;
113
+ this lane would catch integration drift the fakes can't.
114
+ - **Namespace scoping** once the Python SDK honors it on ingest/list/delete (today
115
+ only search/package), at which point the `namespace` input returns.
116
+ - **Branded AtomicMemory icon** (vendor logo, like the model providers') — **deferred**.
117
+ Each component currently uses a distinct Lucide icon (`save` / `search` /
118
+ `messages-square` / `trash`). A real brand mark is a Langflow *vendor icon*, which
119
+ per Langflow's docs requires frontend changes (an `@/icons/AtomicMemory` SVG +
120
+ forwardRef wrapper + a `lazyIconImports` entry) and so cannot ship from a Python
121
+ component bundle — it needs an upstream Langflow PR. Logo SVGs exist under
122
+ `supermem-internal-web/static/img/`.
@@ -0,0 +1,16 @@
1
+ atomicmemory_langflow/__init__.py,sha256=6Wmwc7_WALEKWoB81f8GtQuoZsPulQ3r8ThkPHXjmLQ,922
2
+ atomicmemory_langflow/_chat_history.py,sha256=TM0mAwEeo2262jmVssnJ38mG0Bn9RiV4NfKlkQBMHx8,2309
3
+ atomicmemory_langflow/_component_base.py,sha256=uIi0XyhSzSNIzAmVhgjYcZyPrN0UoEHKLiWIDAwtV0k,2092
4
+ atomicmemory_langflow/_inputs.py,sha256=bA4MpD1RoqaSY7H__2SiDwL1XEK7nk1my27uyYWOMbE,2288
5
+ atomicmemory_langflow/_messages.py,sha256=m9e30U5u5-FLLtHlF2wA2s02Iq2gKNiLx2VnNDUZuMA,2488
6
+ atomicmemory_langflow/_scope.py,sha256=OhhyBRWn9cQL8FQnHflCSsH8_7xwsDl8PE9d-P5QVDQ,1033
7
+ atomicmemory_langflow/_sdk.py,sha256=Xl2X01blvy-VVueepvHgvI4I_W-zzyUmLIk-iNwMFdc,9379
8
+ atomicmemory_langflow/chat_memory.py,sha256=j4-MwH_aETGbDaAZGbKPzPY9C1el41TfUQw0ka7FHyI,1976
9
+ atomicmemory_langflow/delete.py,sha256=iML1ckmhOOyY-hauQuAbesBCh0qXJ--zUGIVkwefzOs,2021
10
+ atomicmemory_langflow/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ atomicmemory_langflow/search_context.py,sha256=k9m1XNfoYUhXrKTelbDloSn1FNA-3iuhEjsoMuxWbCM,3942
12
+ atomicmemory_langflow/store_message.py,sha256=HtQpjpRGGl-TSQ8_lg38k7EbnxZoKYehsd62qf6vwLU,3244
13
+ atomicmemory_langflow-0.1.17.dist-info/METADATA,sha256=rnhVgWOwDPBEBvZHZ_RkG5ht1XsQM0kCvY2TtDhef1s,6546
14
+ atomicmemory_langflow-0.1.17.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
15
+ atomicmemory_langflow-0.1.17.dist-info/top_level.txt,sha256=UbvY-0cwzAFYyiRSMHXHxVV59K0tohp7dYXlmqplrXU,22
16
+ atomicmemory_langflow-0.1.17.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ atomicmemory_langflow