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.
- atomicmemory_langflow/__init__.py +30 -0
- atomicmemory_langflow/_chat_history.py +60 -0
- atomicmemory_langflow/_component_base.py +51 -0
- atomicmemory_langflow/_inputs.py +72 -0
- atomicmemory_langflow/_messages.py +68 -0
- atomicmemory_langflow/_scope.py +40 -0
- atomicmemory_langflow/_sdk.py +234 -0
- atomicmemory_langflow/chat_memory.py +55 -0
- atomicmemory_langflow/delete.py +56 -0
- atomicmemory_langflow/py.typed +0 -0
- atomicmemory_langflow/search_context.py +100 -0
- atomicmemory_langflow/store_message.py +81 -0
- atomicmemory_langflow-0.1.17.dist-info/METADATA +122 -0
- atomicmemory_langflow-0.1.17.dist-info/RECORD +16 -0
- atomicmemory_langflow-0.1.17.dist-info/WHEEL +5 -0
- atomicmemory_langflow-0.1.17.dist-info/top_level.txt +1 -0
|
@@ -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 @@
|
|
|
1
|
+
atomicmemory_langflow
|