atomir 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. atomir-0.1.0/LICENSE +21 -0
  2. atomir-0.1.0/PKG-INFO +155 -0
  3. atomir-0.1.0/README.md +110 -0
  4. atomir-0.1.0/atomir/__init__.py +9 -0
  5. atomir-0.1.0/atomir/api.py +68 -0
  6. atomir-0.1.0/atomir/assembly.py +41 -0
  7. atomir-0.1.0/atomir/atomic_read.py +76 -0
  8. atomir-0.1.0/atomir/client.py +64 -0
  9. atomir-0.1.0/atomir/config.py +112 -0
  10. atomir-0.1.0/atomir/embeddings/__init__.py +11 -0
  11. atomir-0.1.0/atomir/embeddings/fake.py +48 -0
  12. atomir-0.1.0/atomir/embeddings/jina.py +86 -0
  13. atomir-0.1.0/atomir/extractor.py +34 -0
  14. atomir-0.1.0/atomir/llm/__init__.py +11 -0
  15. atomir-0.1.0/atomir/llm/fake.py +46 -0
  16. atomir-0.1.0/atomir/llm/groq.py +85 -0
  17. atomir-0.1.0/atomir/llm/parsing.py +64 -0
  18. atomir-0.1.0/atomir/locking.py +26 -0
  19. atomir-0.1.0/atomir/memory.py +77 -0
  20. atomir-0.1.0/atomir/providers/__init__.py +11 -0
  21. atomir-0.1.0/atomir/providers/embedder_base.py +27 -0
  22. atomir-0.1.0/atomir/providers/factory.py +57 -0
  23. atomir-0.1.0/atomir/providers/llm_base.py +20 -0
  24. atomir-0.1.0/atomir/reconciler.py +92 -0
  25. atomir-0.1.0/atomir/store_base.py +79 -0
  26. atomir-0.1.0/atomir/stores/__init__.py +9 -0
  27. atomir-0.1.0/atomir/stores/json_store.py +143 -0
  28. atomir-0.1.0/atomir/stores/qdrant_store.py +151 -0
  29. atomir-0.1.0/atomir.egg-info/PKG-INFO +155 -0
  30. atomir-0.1.0/atomir.egg-info/SOURCES.txt +33 -0
  31. atomir-0.1.0/atomir.egg-info/dependency_links.txt +1 -0
  32. atomir-0.1.0/atomir.egg-info/requires.txt +18 -0
  33. atomir-0.1.0/atomir.egg-info/top_level.txt +1 -0
  34. atomir-0.1.0/pyproject.toml +31 -0
  35. atomir-0.1.0/setup.cfg +4 -0
atomir-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bereket Tilahun Shimekit
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
atomir-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,155 @@
1
+ Metadata-Version: 2.4
2
+ Name: atomir
3
+ Version: 0.1.0
4
+ Summary: Atomic memory infrastructure for agents — atomic facts on write, atomic sub-question decomposition on read.
5
+ Author: bekiTil
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Bereket Tilahun Shimekit
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Requires-Python: >=3.11
29
+ Description-Content-Type: text/markdown
30
+ License-File: LICENSE
31
+ Requires-Dist: python-dotenv>=1.0
32
+ Requires-Dist: numpy>=1.26
33
+ Provides-Extra: groq
34
+ Provides-Extra: jina
35
+ Provides-Extra: qdrant
36
+ Requires-Dist: qdrant-client>=1.7; extra == "qdrant"
37
+ Provides-Extra: api
38
+ Requires-Dist: fastapi>=0.100; extra == "api"
39
+ Requires-Dist: uvicorn[standard]>=0.23; extra == "api"
40
+ Provides-Extra: all
41
+ Requires-Dist: qdrant-client>=1.7; extra == "all"
42
+ Requires-Dist: fastapi>=0.100; extra == "all"
43
+ Requires-Dist: uvicorn[standard]>=0.23; extra == "all"
44
+ Dynamic: license-file
45
+
46
+ # atomir
47
+
48
+ Atomic memory infrastructure for agents. **Memory is atomic on both ends:**
49
+ atomic facts on write (extract → reconcile), atomic sub-question decomposition on
50
+ read (decompose → retrieve per sub-question → union).
51
+
52
+ ## The thesis
53
+
54
+ Most memory systems store raw text blobs and retrieve with a single fuzzy
55
+ similarity search. atomir does the opposite at both ends:
56
+
57
+ - **Write** — a message is split into small, self-contained facts, and each is
58
+ *reconciled* into memory (ADD new, UPDATE a changed value keeping history,
59
+ DELETE what's no longer true, NOOP duplicates). A similarity gate biases toward
60
+ ADD so distinct facts never over-merge.
61
+ - **Read** — a question is decomposed into atomic sub-questions (only when it
62
+ helps), each retrieved independently, then results are unioned. This surfaces
63
+ facts a single whole-question embedding misses.
64
+
65
+ ## Vendor-neutral by construction
66
+
67
+ The LLM, the embedder, and the vector store are each an **interface** chosen at
68
+ runtime by config (`{provider, config}` blocks). The engine imports only the
69
+ interfaces — never a provider SDK or vendor name. Swapping Groq↔OpenAI,
70
+ Jina↔Voyage, or Qdrant↔pgvector is one config change plus one small class.
71
+ Defaults use `fake` backends, so everything runs with **no external keys**.
72
+
73
+ ## Install
74
+
75
+ ```bash
76
+ pip install -e . # core (offline: fake LLM + fake embedder + JSON store)
77
+ pip install -e ".[qdrant]" # add the Qdrant backend
78
+ pip install -e ".[api]" # add the FastAPI server
79
+ pip install -e ".[all]" # everything
80
+ ```
81
+
82
+ `groq` and `jina` need no extra — they call their HTTP APIs over the standard
83
+ library.
84
+
85
+ ## Quickstart — embedded, no Docker
86
+
87
+ Runs fully offline with the default `fake` backends:
88
+
89
+ ```python
90
+ from atomir.assembly import build_memory_service
91
+
92
+ mem = build_memory_service() # backends chosen by .env
93
+ mem.add("user123", "I'm vegetarian and my manager is Dana Lopez.")
94
+ mem.add("user123", "I'm working on Project Atlas.")
95
+
96
+ hits = mem.search("user123", "who should I email about my project?")
97
+ print(hits["subquestions"]) # the sub-questions it asked
98
+ for r in hits["results"]:
99
+ print(r["text"], round(r["score"], 3))
100
+
101
+ mem.get_all("user123")
102
+ mem.delete("user123", fact_id)
103
+ mem.reset("user123")
104
+ ```
105
+
106
+ To use real providers, copy `.env.example` to `.env` and set the keys/backends.
107
+
108
+ ## Production — Docker Compose (API + Qdrant server)
109
+
110
+ ```bash
111
+ cp .env.example .env # optional: add real keys; without it, LLM/embedder run fake
112
+ docker compose up --build # brings up the API and a Qdrant server
113
+ ```
114
+
115
+ The API points at the Qdrant service via `STORE_URL=http://qdrant:6333`. Then:
116
+
117
+ ```bash
118
+ curl -XPOST localhost:8000/memories -H 'content-type: application/json' \
119
+ -d '{"user_id":"u1","text":"My manager is Dana."}'
120
+ curl -XPOST localhost:8000/search -H 'content-type: application/json' \
121
+ -d '{"user_id":"u1","query":"who is my manager?"}'
122
+ ```
123
+
124
+ ## HTTP endpoints
125
+
126
+ | Method | Path | Body / query | Returns |
127
+ |---|---|---|---|
128
+ | POST | `/memories` | `{user_id, text}` | `{operations, facts}` |
129
+ | POST | `/search` | `{user_id, query, k?, decompose?}` | `{subquestions, results}` |
130
+ | GET | `/memories` | `?user_id=` | list of facts |
131
+ | DELETE | `/memories/{id}` | `?user_id=` | `{deleted, id}` (404 if absent) |
132
+ | DELETE | `/memories` | `?user_id=` | `{reset}` |
133
+ | GET | `/health` | — | `{status, store, llm, embedder}` |
134
+
135
+ `MemoryClient(base_url)` (in `atomir.client`) wraps these with the same method
136
+ names and return shapes.
137
+
138
+ ## Configuration
139
+
140
+ All config is read from the environment (see `.env.example`): `LLM_BACKEND`,
141
+ `LLM_API_KEY`, `MODEL`, `EMBED_BACKEND`, `EMBED_API_KEY`, `EMBED_DIM`,
142
+ `RECONCILE_MIN_SIM`, `STORE_BACKEND`, `COLLECTION`, `STORE_URL`, `STORE_PATH`.
143
+
144
+ ## Known limitations
145
+
146
+ - **Reconciler threshold is untuned.** `RECONCILE_MIN_SIM` defaults to `0.6`; on
147
+ Jina, real "same-attribute" pairs measured ~0.6, so it sits right on the edge.
148
+ It should be tuned per embedder with the eval harness, not trusted as-is.
149
+ - **The JSON backend is NOT crash-safe.** It rewrites the whole file without
150
+ atomic replace/fsync — dev and tests only. Use Qdrant for durable storage.
151
+ - **No transactions.** Writes are serialized per user with a simple lock
152
+ (Step 9); full transactional rollback is deferred (DECISION #5).
153
+ - **Read returns facts, not a composed answer.** `search` returns the relevant
154
+ facts and sub-questions; turning them into a final sentence is the caller's
155
+ LLM's job.
atomir-0.1.0/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # atomir
2
+
3
+ Atomic memory infrastructure for agents. **Memory is atomic on both ends:**
4
+ atomic facts on write (extract → reconcile), atomic sub-question decomposition on
5
+ read (decompose → retrieve per sub-question → union).
6
+
7
+ ## The thesis
8
+
9
+ Most memory systems store raw text blobs and retrieve with a single fuzzy
10
+ similarity search. atomir does the opposite at both ends:
11
+
12
+ - **Write** — a message is split into small, self-contained facts, and each is
13
+ *reconciled* into memory (ADD new, UPDATE a changed value keeping history,
14
+ DELETE what's no longer true, NOOP duplicates). A similarity gate biases toward
15
+ ADD so distinct facts never over-merge.
16
+ - **Read** — a question is decomposed into atomic sub-questions (only when it
17
+ helps), each retrieved independently, then results are unioned. This surfaces
18
+ facts a single whole-question embedding misses.
19
+
20
+ ## Vendor-neutral by construction
21
+
22
+ The LLM, the embedder, and the vector store are each an **interface** chosen at
23
+ runtime by config (`{provider, config}` blocks). The engine imports only the
24
+ interfaces — never a provider SDK or vendor name. Swapping Groq↔OpenAI,
25
+ Jina↔Voyage, or Qdrant↔pgvector is one config change plus one small class.
26
+ Defaults use `fake` backends, so everything runs with **no external keys**.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install -e . # core (offline: fake LLM + fake embedder + JSON store)
32
+ pip install -e ".[qdrant]" # add the Qdrant backend
33
+ pip install -e ".[api]" # add the FastAPI server
34
+ pip install -e ".[all]" # everything
35
+ ```
36
+
37
+ `groq` and `jina` need no extra — they call their HTTP APIs over the standard
38
+ library.
39
+
40
+ ## Quickstart — embedded, no Docker
41
+
42
+ Runs fully offline with the default `fake` backends:
43
+
44
+ ```python
45
+ from atomir.assembly import build_memory_service
46
+
47
+ mem = build_memory_service() # backends chosen by .env
48
+ mem.add("user123", "I'm vegetarian and my manager is Dana Lopez.")
49
+ mem.add("user123", "I'm working on Project Atlas.")
50
+
51
+ hits = mem.search("user123", "who should I email about my project?")
52
+ print(hits["subquestions"]) # the sub-questions it asked
53
+ for r in hits["results"]:
54
+ print(r["text"], round(r["score"], 3))
55
+
56
+ mem.get_all("user123")
57
+ mem.delete("user123", fact_id)
58
+ mem.reset("user123")
59
+ ```
60
+
61
+ To use real providers, copy `.env.example` to `.env` and set the keys/backends.
62
+
63
+ ## Production — Docker Compose (API + Qdrant server)
64
+
65
+ ```bash
66
+ cp .env.example .env # optional: add real keys; without it, LLM/embedder run fake
67
+ docker compose up --build # brings up the API and a Qdrant server
68
+ ```
69
+
70
+ The API points at the Qdrant service via `STORE_URL=http://qdrant:6333`. Then:
71
+
72
+ ```bash
73
+ curl -XPOST localhost:8000/memories -H 'content-type: application/json' \
74
+ -d '{"user_id":"u1","text":"My manager is Dana."}'
75
+ curl -XPOST localhost:8000/search -H 'content-type: application/json' \
76
+ -d '{"user_id":"u1","query":"who is my manager?"}'
77
+ ```
78
+
79
+ ## HTTP endpoints
80
+
81
+ | Method | Path | Body / query | Returns |
82
+ |---|---|---|---|
83
+ | POST | `/memories` | `{user_id, text}` | `{operations, facts}` |
84
+ | POST | `/search` | `{user_id, query, k?, decompose?}` | `{subquestions, results}` |
85
+ | GET | `/memories` | `?user_id=` | list of facts |
86
+ | DELETE | `/memories/{id}` | `?user_id=` | `{deleted, id}` (404 if absent) |
87
+ | DELETE | `/memories` | `?user_id=` | `{reset}` |
88
+ | GET | `/health` | — | `{status, store, llm, embedder}` |
89
+
90
+ `MemoryClient(base_url)` (in `atomir.client`) wraps these with the same method
91
+ names and return shapes.
92
+
93
+ ## Configuration
94
+
95
+ All config is read from the environment (see `.env.example`): `LLM_BACKEND`,
96
+ `LLM_API_KEY`, `MODEL`, `EMBED_BACKEND`, `EMBED_API_KEY`, `EMBED_DIM`,
97
+ `RECONCILE_MIN_SIM`, `STORE_BACKEND`, `COLLECTION`, `STORE_URL`, `STORE_PATH`.
98
+
99
+ ## Known limitations
100
+
101
+ - **Reconciler threshold is untuned.** `RECONCILE_MIN_SIM` defaults to `0.6`; on
102
+ Jina, real "same-attribute" pairs measured ~0.6, so it sits right on the edge.
103
+ It should be tuned per embedder with the eval harness, not trusted as-is.
104
+ - **The JSON backend is NOT crash-safe.** It rewrites the whole file without
105
+ atomic replace/fsync — dev and tests only. Use Qdrant for durable storage.
106
+ - **No transactions.** Writes are serialized per user with a simple lock
107
+ (Step 9); full transactional rollback is deferred (DECISION #5).
108
+ - **Read returns facts, not a composed answer.** `search` returns the relevant
109
+ facts and sub-questions; turning them into a final sentence is the caller's
110
+ LLM's job.
@@ -0,0 +1,9 @@
1
+ """atomir — atomic memory infrastructure for agents.
2
+
3
+ Memory is atomic on both ends: atomic facts on write, atomic sub-question
4
+ decomposition on read. This package is built inside-out (engine → storage →
5
+ multi-tenancy → API → packaging) and is vendor-neutral: the LLM, embedder, and
6
+ vector store are each an interface chosen at runtime by config.
7
+ """
8
+
9
+ __version__ = "0.0.0"
@@ -0,0 +1,68 @@
1
+ """HTTP surface over the engine (FastAPI). A thin adapter, no business logic.
2
+
3
+ Endpoints unpack a request, call ONE `MemoryService` method, and return its
4
+ result. Handlers are plain `def` (not `async def`) so Starlette runs the
5
+ blocking engine work in a threadpool instead of stalling the event loop; the
6
+ per-user lock inside `MemoryService` keeps same-user writes correct across those
7
+ threads.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from fastapi import FastAPI, HTTPException, Query
13
+ from pydantic import BaseModel
14
+
15
+ from atomir.assembly import build_memory_service
16
+ from atomir.config import settings
17
+
18
+ service = build_memory_service()
19
+ app = FastAPI(title="atomir", version="0.1.0")
20
+
21
+
22
+ class AddBody(BaseModel):
23
+ user_id: str
24
+ text: str
25
+
26
+
27
+ class SearchBody(BaseModel):
28
+ user_id: str
29
+ query: str
30
+ k: int = 6
31
+ decompose: bool = True
32
+
33
+
34
+ @app.post("/memories")
35
+ def add_memories(body: AddBody) -> dict:
36
+ return service.add(body.user_id, body.text)
37
+
38
+
39
+ @app.post("/search")
40
+ def search(body: SearchBody) -> dict:
41
+ return service.search(body.user_id, body.query, k=body.k, decompose=body.decompose)
42
+
43
+
44
+ @app.get("/memories")
45
+ def get_all(user_id: str = Query(...)) -> list[dict]:
46
+ return service.get_all(user_id)
47
+
48
+
49
+ @app.delete("/memories/{fact_id}")
50
+ def delete_one(fact_id: str, user_id: str = Query(...)) -> dict:
51
+ if not service.delete(user_id, fact_id):
52
+ raise HTTPException(status_code=404, detail="fact not found for this user")
53
+ return {"deleted": True, "id": fact_id}
54
+
55
+
56
+ @app.delete("/memories")
57
+ def reset(user_id: str = Query(...)) -> dict:
58
+ return {"reset": service.reset(user_id)}
59
+
60
+
61
+ @app.get("/health")
62
+ def health() -> dict:
63
+ return {
64
+ "status": "ok",
65
+ "store": settings.store_backend,
66
+ "llm": settings.llm_backend,
67
+ "embedder": settings.embed_backend,
68
+ }
@@ -0,0 +1,41 @@
1
+ """Assembly: build a `MemoryService` from config.
2
+
3
+ This is the OUTERMOST wiring layer — the one place allowed to name concrete
4
+ backends. It selects the LLM and embedder through their factories and the store
5
+ through a small backend switch, then injects all three into the (vendor-neutral)
6
+ `MemoryService`. Concrete backends are imported lazily so building a fake/JSON
7
+ service never requires an unused provider SDK.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from atomir.config import Settings
13
+ from atomir.config import make_qdrant_client
14
+ from atomir.config import settings as default_settings
15
+ from atomir.memory import MemoryService
16
+ from atomir.providers import EmbedderFactory, LLMFactory
17
+ from atomir.store_base import MemoryStore
18
+
19
+
20
+ def _build_store(s: Settings) -> MemoryStore:
21
+ provider = s.store_backend
22
+ if provider == "qdrant":
23
+ from atomir.stores.qdrant_store import QdrantMemoryStore
24
+
25
+ return QdrantMemoryStore(make_qdrant_client(), s.collection, s.embed_dim)
26
+ if provider == "json":
27
+ from atomir.stores.json_store import JsonMemoryStore
28
+
29
+ path = s.store_path if s.store_path.endswith(".json") else "./atomir_store.json"
30
+ return JsonMemoryStore(path=path)
31
+ raise ValueError(
32
+ f"Unknown store backend {provider!r}. Valid backends: ['qdrant', 'json']"
33
+ )
34
+
35
+
36
+ def build_memory_service(s: Settings = default_settings) -> MemoryService:
37
+ """Construct a MemoryService with providers/backend selected purely by config."""
38
+ llm = LLMFactory.create(s.llm)
39
+ embedder = EmbedderFactory.create(s.embedder)
40
+ store = _build_store(s)
41
+ return MemoryService(store, llm, embedder, reconcile_min_sim=s.reconcile_min_sim)
@@ -0,0 +1,76 @@
1
+ """Atomic READ: answer a question by querying memory atomically.
2
+
3
+ The read-side twin of the write engine. Instead of embedding a whole question
4
+ as one blob, a planner may decompose it into atomic sub-questions; each is
5
+ retrieved independently and the results are unioned (deduped by fact id, best
6
+ score kept). Vendor-neutral: imports ONLY the injected interfaces.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from atomir.providers.embedder_base import Embedder
12
+ from atomir.providers.llm_base import LLM
13
+ from atomir.store_base import MemoryStore
14
+
15
+ _PLAN_SYSTEM = (
16
+ "You are a query planner for a personal-memory system. Decide whether a "
17
+ "question must be broken into atomic sub-questions to answer it.\n"
18
+ "- SIMPLE single-fact question -> decompose=false and subquestions=[the "
19
+ "original question verbatim].\n"
20
+ "- MULTI-HOP question (the answer depends on an intermediate fact, e.g. a "
21
+ "person, project, or relationship you must resolve first) -> decompose=true "
22
+ "with atomic wh- sub-questions, INCLUDING the intermediate facts.\n\n"
23
+ "Examples:\n"
24
+ 'Q: "where does the user live?"\n'
25
+ '{"decompose": false, "subquestions": ["where does the user live?"]}\n'
26
+ 'Q: "what gift should I buy my sister?"\n'
27
+ '{"decompose": true, "subquestions": ["who is the user\'s sister?", '
28
+ '"what does the user\'s sister like?"]}\n\n'
29
+ "Respond ONLY with JSON: "
30
+ '{"decompose": <true|false>, "subquestions": ["...", "..."]}'
31
+ )
32
+
33
+
34
+ def plan(llm: LLM, query: str) -> dict:
35
+ """Decide whether/how to decompose `query`. Always returns >=1 subquestion."""
36
+ result = llm.chat_json(_PLAN_SYSTEM, query)
37
+ decompose = bool(result.get("decompose", False))
38
+ subs = [
39
+ s.strip()
40
+ for s in (result.get("subquestions") or [])
41
+ if isinstance(s, str) and s.strip()
42
+ ]
43
+ if not subs: # planner gave nothing usable -> fall back to the raw query
44
+ subs, decompose = [query], False
45
+ return {"decompose": decompose, "subquestions": subs}
46
+
47
+
48
+ def atomic_search(
49
+ store: MemoryStore,
50
+ llm: LLM,
51
+ embedder: Embedder,
52
+ user_id: str,
53
+ query: str,
54
+ k: int = 6,
55
+ decompose: bool = True,
56
+ ) -> dict:
57
+ """Retrieve facts for `query`, decomposing into sub-questions when useful.
58
+
59
+ Returns {"subquestions": [...], "results": [...]} — sub-questions are
60
+ exposed deliberately (the differentiator, and a debugging aid).
61
+ """
62
+ subquestions = plan(llm, query)["subquestions"] if decompose else [query]
63
+
64
+ # Each sub-question is embedded as a QUERY (here the input really is a
65
+ # question) and retrieved independently. These retrievals are mutually
66
+ # independent -> safe to run concurrently (deferred to Step 9); sequential
67
+ # here for determinism.
68
+ best_by_id: dict[str, dict] = {}
69
+ for sq in subquestions:
70
+ for hit in store.search(user_id, embedder.embed_query(sq), k=k):
71
+ hid = hit["id"]
72
+ if hid not in best_by_id or hit["score"] > best_by_id[hid]["score"]:
73
+ best_by_id[hid] = hit
74
+
75
+ results = sorted(best_by_id.values(), key=lambda h: h["score"], reverse=True)[:k]
76
+ return {"subquestions": subquestions, "results": results}
@@ -0,0 +1,64 @@
1
+ """Client SDK: adoption in one import.
2
+
3
+ `MemoryClient` mirrors the HTTP API (and the `MemoryService` facade) method for
4
+ method, with IDENTICAL return shapes — so callers can move between the library,
5
+ the service, and this SDK without changing code. Built on stdlib urllib, so the
6
+ SDK adds no dependency.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import urllib.error
13
+ import urllib.request
14
+ from urllib.parse import urlencode
15
+
16
+
17
+ class MemoryClient:
18
+ """Thin REST wrapper around an atomir API server."""
19
+
20
+ def __init__(self, base_url: str = "http://localhost:8000", timeout: float = 30.0) -> None:
21
+ self.base_url = base_url.rstrip("/")
22
+ self.timeout = timeout
23
+
24
+ def _request(self, method: str, path: str, *, body: dict | None = None, params: dict | None = None):
25
+ url = self.base_url + path
26
+ if params:
27
+ url += "?" + urlencode(params)
28
+ data = json.dumps(body).encode("utf-8") if body is not None else None
29
+ req = urllib.request.Request(
30
+ url,
31
+ data=data,
32
+ method=method,
33
+ headers={"Content-Type": "application/json"} if data is not None else {},
34
+ )
35
+ try:
36
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
37
+ raw = resp.read().decode("utf-8")
38
+ return json.loads(raw) if raw else None
39
+ except urllib.error.HTTPError as e:
40
+ detail = e.read().decode("utf-8", "replace")[:300]
41
+ raise RuntimeError(
42
+ f"atomir API {method} {path} failed: {e.code} {e.reason} — {detail}"
43
+ ) from e
44
+
45
+ def add(self, user_id: str, text: str) -> dict:
46
+ return self._request("POST", "/memories", body={"user_id": user_id, "text": text})
47
+
48
+ def search(self, user_id: str, query: str, k: int = 6, decompose: bool = True) -> dict:
49
+ return self._request(
50
+ "POST", "/search",
51
+ body={"user_id": user_id, "query": query, "k": k, "decompose": decompose},
52
+ )
53
+
54
+ def get_all(self, user_id: str) -> list[dict]:
55
+ return self._request("GET", "/memories", params={"user_id": user_id})
56
+
57
+ def delete(self, user_id: str, fact_id: str) -> dict:
58
+ return self._request("DELETE", f"/memories/{fact_id}", params={"user_id": user_id})
59
+
60
+ def reset(self, user_id: str) -> dict:
61
+ return self._request("DELETE", "/memories", params={"user_id": user_id})
62
+
63
+ def health(self) -> dict:
64
+ return self._request("GET", "/health")
@@ -0,0 +1,112 @@
1
+ """Centralized, vendor-neutral configuration.
2
+
3
+ 12-factor (factor III): all config that varies between deploys — keys, model
4
+ names, URLs, paths — is read from the environment, never hardcoded. A local
5
+ `.env` file is loaded for convenience in development.
6
+
7
+ The design exposes THREE independent, swappable provider slots — `llm`,
8
+ `embedder`, and `vector_store` — each as a mem0-style `{provider, config}`
9
+ block. No vendor is privileged: whichever providers the environment selects are
10
+ the ones used. The first-run example happens to be Groq + Jina + Qdrant, but
11
+ each is just one value of an interface that gets formalized in later steps.
12
+
13
+ Defaults favor running with NO external keys: both the LLM and embedder default
14
+ to the `fake` backend so the system is testable offline.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ from dataclasses import dataclass, field
21
+
22
+ from dotenv import load_dotenv
23
+
24
+ # Load .env into os.environ if present (no error if the file is missing).
25
+ load_dotenv()
26
+
27
+
28
+ def _env(name: str, default: str = "") -> str:
29
+ return os.environ.get(name, default)
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Settings:
34
+ """Immutable snapshot of configuration read from the environment."""
35
+
36
+ # --- LLM slot ---------------------------------------------------------
37
+ # Field names stay vendor-neutral: `llm_backend` selects the impl, and the
38
+ # key/model are read into generic fields. `groq` is just the first example.
39
+ llm_backend: str = field(default_factory=lambda: _env("LLM_BACKEND", "fake"))
40
+ llm_api_key: str = field(default_factory=lambda: _env("LLM_API_KEY"))
41
+ model: str = field(default_factory=lambda: _env("MODEL", "llama-3.3-70b-versatile"))
42
+
43
+ # --- Embedder slot ----------------------------------------------------
44
+ # Same neutrality: `jina` is the first example, but nothing here names it.
45
+ embed_backend: str = field(default_factory=lambda: _env("EMBED_BACKEND", "fake"))
46
+ embed_api_key: str = field(default_factory=lambda: _env("EMBED_API_KEY"))
47
+ # Dimension travels WITH the embedder choice (Jina v3 = 1024).
48
+ embed_dim: int = field(default_factory=lambda: int(_env("EMBED_DIM", "1024")))
49
+
50
+ # --- Reconciler tuning ------------------------------------------------
51
+ # Write-side similarity gate (DECISION #1a): a candidate whose nearest
52
+ # existing fact scores below this is ADDed directly, without asking the LLM.
53
+ reconcile_min_sim: float = field(
54
+ default_factory=lambda: float(_env("RECONCILE_MIN_SIM", "0.6"))
55
+ )
56
+
57
+ # --- Vector store slot ------------------------------------------------
58
+ # Selected by `store_backend` like the other two slots (no hardcoded vendor).
59
+ store_backend: str = field(default_factory=lambda: _env("STORE_BACKEND", "qdrant"))
60
+ collection: str = field(default_factory=lambda: _env("COLLECTION", "atomir_memories"))
61
+ # `url`/`path` are generic connection params (Qdrant uses both today).
62
+ store_url: str = field(default_factory=lambda: _env("STORE_URL"))
63
+ store_path: str = field(default_factory=lambda: _env("STORE_PATH", "./qdrant_data"))
64
+
65
+ # --- Vendor-neutral provider blocks (mem0-style {provider, config}) ---
66
+ # These are how the engine will eventually select implementations without
67
+ # ever naming a vendor. The factories that consume them arrive in Step 5.5.
68
+
69
+ @property
70
+ def llm(self) -> dict:
71
+ return {
72
+ "provider": self.llm_backend,
73
+ "config": {"api_key": self.llm_api_key, "model": self.model},
74
+ }
75
+
76
+ @property
77
+ def embedder(self) -> dict:
78
+ return {
79
+ "provider": self.embed_backend,
80
+ "config": {"api_key": self.embed_api_key, "embed_dim": self.embed_dim},
81
+ }
82
+
83
+ @property
84
+ def vector_store(self) -> dict:
85
+ return {
86
+ "provider": self.store_backend,
87
+ "config": {
88
+ "url": self.store_url,
89
+ "path": self.store_path,
90
+ "collection": self.collection,
91
+ "embed_dim": self.embed_dim,
92
+ },
93
+ }
94
+
95
+
96
+ # A single shared instance importers use: `from atomir.config import settings`.
97
+ settings = Settings()
98
+
99
+
100
+ def make_qdrant_client():
101
+ """Build a QdrantClient from settings: server mode if a URL is set, else
102
+ embedded/local at the configured path (zero-ops).
103
+
104
+ The qdrant SDK is imported lazily so merely importing this config module
105
+ never requires the optional provider dependency (offline/fake path stays
106
+ dependency-free). Returns a `qdrant_client.QdrantClient`.
107
+ """
108
+ from qdrant_client import QdrantClient
109
+
110
+ if settings.store_url:
111
+ return QdrantClient(url=settings.store_url)
112
+ return QdrantClient(path=settings.store_path)
@@ -0,0 +1,11 @@
1
+ """Embedder implementations.
2
+
3
+ The formal `Embedder` interface is introduced in Step 5.5; for now these are
4
+ concrete, duck-typed classes that all expose the same two methods:
5
+ `embed_passage(text) -> list[float]` and `embed_query(text) -> list[float]`.
6
+ """
7
+
8
+ from atomir.embeddings.fake import FakeEmbedder
9
+ from atomir.embeddings.jina import JinaEmbedder
10
+
11
+ __all__ = ["FakeEmbedder", "JinaEmbedder"]