mimir-learn 0.1.0__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.
mimir/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """Mimir — experience-driven memory for autonomous agents."""
2
+
3
+ from .core import Mimir
4
+ from .embeddings import Embedder, NullEmbedder
5
+ from .models import Experience, Outcome, Recommendation
6
+ from .storage import SQLiteStorage, Storage
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ __all__ = [
11
+ "Mimir",
12
+ "Experience",
13
+ "Outcome",
14
+ "Recommendation",
15
+ "Storage",
16
+ "SQLiteStorage",
17
+ "Embedder",
18
+ "NullEmbedder",
19
+ ]
mimir/core.py ADDED
@@ -0,0 +1,201 @@
1
+ """The Mimir public API.
2
+
3
+ Everything users touch goes through this class. All writes funnel through a
4
+ single chokepoint (`_write`) — that one method is the seam where validation,
5
+ provenance tagging, and (future) a memory-firewall layer plug in without
6
+ touching the rest of the system.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import math
13
+ import threading
14
+ from collections import defaultdict
15
+
16
+ from .embeddings import Embedder, NullEmbedder, cosine_similarity
17
+ from .models import Experience, Outcome, Recommendation
18
+ from .storage import SQLiteStorage, Storage
19
+
20
+
21
+ class Mimir:
22
+ def __init__(
23
+ self,
24
+ db_path: str = "mimir.db",
25
+ *,
26
+ storage: Storage | None = None,
27
+ embedder: Embedder | None = None,
28
+ ) -> None:
29
+ self._storage = storage or SQLiteStorage(db_path)
30
+ self._embedder = embedder or NullEmbedder()
31
+ self._lock = threading.Lock()
32
+
33
+ # -- writes -------------------------------------------------------------
34
+
35
+ def record(
36
+ self,
37
+ task: str,
38
+ action: str,
39
+ outcome: str | Outcome = Outcome.SUCCESS,
40
+ score: float | None = None,
41
+ context: dict | None = None,
42
+ ) -> Experience:
43
+ """Record an experience: a task, the action taken, and how it went."""
44
+ outcome = Outcome(outcome) if not isinstance(outcome, Outcome) else outcome
45
+ if score is None:
46
+ score = _default_score(outcome)
47
+ exp = Experience(
48
+ task=task,
49
+ action=action,
50
+ outcome=outcome,
51
+ score=score,
52
+ context=context or {},
53
+ )
54
+ return self._write(exp)
55
+
56
+ def record_failure(
57
+ self,
58
+ task: str,
59
+ action: str,
60
+ reason: str | None = None,
61
+ score: float = 0.0,
62
+ context: dict | None = None,
63
+ ) -> Experience:
64
+ """Record a failure. Stored like any experience but with outcome=failure,
65
+ so agents can recall what *didn't* work and stop repeating it."""
66
+ ctx = dict(context or {})
67
+ if reason:
68
+ ctx["failure_reason"] = reason
69
+ return self.record(task, action, outcome=Outcome.FAILURE, score=score, context=ctx)
70
+
71
+ def _write(self, exp: Experience) -> Experience:
72
+ """The single write chokepoint. Validation/provenance/firewall hooks go here."""
73
+ # Fail fast with a clear message rather than crashing deep in storage.
74
+ try:
75
+ json.dumps(exp.context)
76
+ except (TypeError, ValueError) as exc:
77
+ raise ValueError(f"context must be JSON-serializable: {exc}") from exc
78
+ with self._lock:
79
+ if self._embedder.enabled and exp.embedding is None:
80
+ exp.embedding = self._embedder.embed(exp.text())
81
+ self._storage.add(exp)
82
+ return exp
83
+
84
+ # -- reads --------------------------------------------------------------
85
+
86
+ def recall(
87
+ self,
88
+ query: str,
89
+ k: int = 5,
90
+ outcome: str | Outcome | None = None,
91
+ context: dict | None = None,
92
+ ) -> list[Experience]:
93
+ """Return the most relevant past experiences for ``query``."""
94
+ outcome_val = outcome.value if isinstance(outcome, Outcome) else outcome
95
+ # Pull a wider candidate set so optional embedding rerank has room to work.
96
+ candidates = self._storage.search(
97
+ query, k=max(k * 4, k), outcome=outcome_val, context=context
98
+ )
99
+ if self._embedder.enabled:
100
+ candidates = self._rerank(query, candidates)
101
+ return [exp for exp, _ in candidates[:k]]
102
+
103
+ def get(self, experience_id: str) -> Experience | None:
104
+ """Fetch a single experience by id, or None if it doesn't exist."""
105
+ return self._storage.get(experience_id)
106
+
107
+ def delete(self, experience_id: str) -> bool:
108
+ """Delete an experience by id. Returns True if it existed."""
109
+ return self._storage.delete(experience_id)
110
+
111
+ def recent(self, n: int = 10) -> list[Experience]:
112
+ """Return the ``n`` most recently recorded experiences, newest first."""
113
+ return self._storage.recent(n)
114
+
115
+ def recommend(self, task: str, k: int = 20) -> Recommendation | None:
116
+ """Suggest a strategy for a new task by aggregating similar past
117
+ experiences. Returns None if there's nothing relevant to go on.
118
+
119
+ Confidence is the Wilson lower bound of the success rate for the
120
+ recommended action — so an action that worked 9/10 times outranks one
121
+ that worked 1/1 time (small samples are penalised, as they should be).
122
+ """
123
+ candidates = self._storage.search(task, k=k)
124
+ if not candidates:
125
+ return None
126
+
127
+ groups: dict[str, list[tuple[Experience, float]]] = defaultdict(list)
128
+ for exp, rel in candidates:
129
+ groups[_normalize(exp.action)].append((exp, rel))
130
+
131
+ best: Recommendation | None = None
132
+ for action_key, items in groups.items():
133
+ succ = sum(1 for e, _ in items if e.outcome is Outcome.SUCCESS)
134
+ fail = sum(1 for e, _ in items if e.outcome is Outcome.FAILURE)
135
+ part = sum(1 for e, _ in items if e.outcome is Outcome.PARTIAL)
136
+ total = len(items)
137
+ effective_successes = succ + 0.5 * part
138
+ confidence = _wilson_lower_bound(effective_successes, total)
139
+ # Use the most relevant phrasing of the action as the display string.
140
+ display_action = max(items, key=lambda pair: pair[1])[0].action
141
+ rec = Recommendation(
142
+ task=task,
143
+ recommended_action=display_action,
144
+ confidence=confidence,
145
+ success_count=succ,
146
+ failure_count=fail,
147
+ partial_count=part,
148
+ based_on=total,
149
+ supporting_ids=[e.id for e, _ in items],
150
+ )
151
+ if best is None or rec.confidence > best.confidence:
152
+ best = rec
153
+ return best
154
+
155
+ def _rerank(
156
+ self, query: str, candidates: list[tuple[Experience, float]]
157
+ ) -> list[tuple[Experience, float]]:
158
+ qvec = self._embedder.embed(query)
159
+ rescored = []
160
+ for exp, kw_score in candidates:
161
+ sem = cosine_similarity(qvec, exp.embedding) if exp.embedding else 0.0
162
+ # Blend keyword and semantic relevance.
163
+ rescored.append((exp, 0.5 * kw_score + 0.5 * sem))
164
+ rescored.sort(key=lambda pair: pair[1], reverse=True)
165
+ return rescored
166
+
167
+ # -- misc ---------------------------------------------------------------
168
+
169
+ def count(self) -> int:
170
+ return self._storage.count()
171
+
172
+ def close(self) -> None:
173
+ self._storage.close()
174
+
175
+ def __enter__(self) -> "Mimir":
176
+ return self
177
+
178
+ def __exit__(self, *exc) -> None:
179
+ self.close()
180
+
181
+
182
+ def _default_score(outcome: Outcome) -> float:
183
+ return {Outcome.SUCCESS: 1.0, Outcome.PARTIAL: 0.5, Outcome.FAILURE: 0.0}[outcome]
184
+
185
+
186
+ def _normalize(action: str) -> str:
187
+ return " ".join(action.lower().split())
188
+
189
+
190
+ def _wilson_lower_bound(successes: float, total: int, z: float = 1.96) -> float:
191
+ """Lower bound of a Wilson score interval for a binomial proportion.
192
+
193
+ Rewards both a high success rate and a large sample size.
194
+ """
195
+ if total == 0:
196
+ return 0.0
197
+ phat = successes / total
198
+ denom = 1 + z * z / total
199
+ centre = phat + z * z / (2 * total)
200
+ margin = z * math.sqrt((phat * (1 - phat) + z * z / (4 * total)) / total)
201
+ return max(0.0, (centre - margin) / denom)
mimir/embeddings.py ADDED
@@ -0,0 +1,39 @@
1
+ """Embedding provider seam.
2
+
3
+ The default is a no-op: recall works on keywords alone, so v1 has no heavy
4
+ dependencies and no API cost. Plug in a real embedder (local or API-backed) to
5
+ enable semantic recall — it just needs to implement ``Embedder``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from abc import ABC, abstractmethod
12
+
13
+
14
+ class Embedder(ABC):
15
+ enabled: bool = True
16
+
17
+ @abstractmethod
18
+ def embed(self, text: str) -> list[float]:
19
+ """Return a vector for ``text``."""
20
+
21
+
22
+ class NullEmbedder(Embedder):
23
+ """Does nothing. Recall falls back to keyword search."""
24
+
25
+ enabled = False
26
+
27
+ def embed(self, text: str) -> list[float]: # pragma: no cover - never called
28
+ raise RuntimeError("NullEmbedder cannot produce embeddings")
29
+
30
+
31
+ def cosine_similarity(a: list[float], b: list[float]) -> float:
32
+ if not a or not b or len(a) != len(b):
33
+ return 0.0
34
+ dot = sum(x * y for x, y in zip(a, b))
35
+ na = math.sqrt(sum(x * x for x in a))
36
+ nb = math.sqrt(sum(y * y for y in b))
37
+ if na == 0 or nb == 0:
38
+ return 0.0
39
+ return dot / (na * nb)
mimir/models.py ADDED
@@ -0,0 +1,89 @@
1
+ """Core data models for Mimir.
2
+
3
+ The whole point of Mimir is that it stores *experiences* (problem -> action ->
4
+ outcome), not documents. These models are that contract.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import uuid
10
+ from datetime import datetime, timezone
11
+ from enum import Enum
12
+
13
+ from pydantic import BaseModel, Field, field_validator
14
+
15
+
16
+ def _now() -> datetime:
17
+ return datetime.now(timezone.utc)
18
+
19
+
20
+ def _new_id() -> str:
21
+ return uuid.uuid4().hex
22
+
23
+
24
+ class Outcome(str, Enum):
25
+ """How an attempt turned out."""
26
+
27
+ SUCCESS = "success"
28
+ FAILURE = "failure"
29
+ PARTIAL = "partial"
30
+
31
+
32
+ class Experience(BaseModel):
33
+ """A single recorded experience: what was attempted and how it went.
34
+
35
+ This is the atomic unit Mimir stores and learns from.
36
+ """
37
+
38
+ id: str = Field(default_factory=_new_id)
39
+ task: str # the problem being solved
40
+ action: str # what was actually done
41
+ outcome: Outcome = Outcome.SUCCESS
42
+ score: float = Field(default=1.0, ge=0.0, le=1.0) # quality/confidence of the outcome
43
+ context: dict = Field(default_factory=dict) # env, tags, agent_id, domain, ...
44
+ embedding: list[float] | None = None # set only when an embedder is configured
45
+ created_at: datetime = Field(default_factory=_now)
46
+ superseded_by: str | None = None # for staleness/versioning (future use)
47
+
48
+ @field_validator("task", "action")
49
+ @classmethod
50
+ def _not_blank(cls, value: str) -> str:
51
+ cleaned = value.strip()
52
+ if not cleaned:
53
+ raise ValueError("must not be empty or whitespace")
54
+ return cleaned
55
+
56
+ def text(self) -> str:
57
+ """The text Mimir indexes and embeds for retrieval."""
58
+ return f"{self.task}\n{self.action}"
59
+
60
+
61
+ class Recommendation(BaseModel):
62
+ """A strategy suggested for a new task, derived from past experiences.
63
+
64
+ In v1 this is computed on the fly by aggregating recalled experiences
65
+ (no LLM). Later phases will materialize these as first-class Strategy rows.
66
+ """
67
+
68
+ task: str # the query this recommendation answers
69
+ recommended_action: str
70
+ confidence: float = Field(ge=0.0, le=1.0)
71
+ success_count: int = 0
72
+ failure_count: int = 0
73
+ partial_count: int = 0
74
+ based_on: int = 0 # number of experiences considered
75
+ supporting_ids: list[str] = Field(default_factory=list)
76
+
77
+ @property
78
+ def total(self) -> int:
79
+ return self.success_count + self.failure_count + self.partial_count
80
+
81
+ def __str__(self) -> str: # nice console output, as shown in the README
82
+ return (
83
+ f"Recommended strategy: {self.recommended_action!r}\n"
84
+ f" confidence: {self.confidence:.2f}\n"
85
+ f" based on {self.total} experiences "
86
+ f"({self.success_count} success / {self.failure_count} failure"
87
+ + (f" / {self.partial_count} partial" if self.partial_count else "")
88
+ + ")"
89
+ )
@@ -0,0 +1,4 @@
1
+ from .base import Storage
2
+ from .sqlite import SQLiteStorage
3
+
4
+ __all__ = ["Storage", "SQLiteStorage"]
mimir/storage/base.py ADDED
@@ -0,0 +1,52 @@
1
+ """Storage interface — the seam that lets Mimir swap backends without a rewrite.
2
+
3
+ v1 ships a SQLite implementation. A Postgres/pgvector backend (for concurrent
4
+ multi-agent writes) and adapters for external memory stores can implement this
5
+ same interface later.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from abc import ABC, abstractmethod
11
+
12
+ from ..models import Experience
13
+
14
+
15
+ class Storage(ABC):
16
+ @abstractmethod
17
+ def add(self, exp: Experience) -> None:
18
+ """Persist a single experience."""
19
+
20
+ @abstractmethod
21
+ def get(self, experience_id: str) -> Experience | None:
22
+ """Fetch one experience by id, or None."""
23
+
24
+ @abstractmethod
25
+ def search(
26
+ self,
27
+ query: str,
28
+ k: int = 5,
29
+ outcome: str | None = None,
30
+ context: dict | None = None,
31
+ ) -> list[tuple[Experience, float]]:
32
+ """Return up to ``k`` (experience, relevance_score) pairs, best first.
33
+
34
+ ``relevance_score`` is in [0, 1]. ``outcome`` and ``context`` are
35
+ optional equality filters applied before ranking.
36
+ """
37
+
38
+ @abstractmethod
39
+ def delete(self, experience_id: str) -> bool:
40
+ """Remove one experience. Returns True if it existed."""
41
+
42
+ @abstractmethod
43
+ def recent(self, n: int = 10) -> list[Experience]:
44
+ """Return the ``n`` most recently recorded experiences, newest first."""
45
+
46
+ @abstractmethod
47
+ def count(self) -> int:
48
+ """Total number of stored experiences."""
49
+
50
+ @abstractmethod
51
+ def close(self) -> None:
52
+ """Release any underlying resources."""
@@ -0,0 +1,230 @@
1
+ """SQLite storage backend — the v1 default.
2
+
3
+ No external service required: the store is a single file (or in-memory). Keyword
4
+ recall uses SQLite's FTS5 when available, and falls back to a portable
5
+ Python token-overlap scorer when it isn't, so this works everywhere CPython does.
6
+
7
+ The backend is internally thread-safe: every connection access is guarded by a
8
+ lock, so reads and writes from multiple threads are serialized safely.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import re
16
+ import sqlite3
17
+ import threading
18
+ from datetime import datetime
19
+
20
+ from ..models import Experience, Outcome
21
+ from .base import Storage
22
+
23
+ _TOKEN = re.compile(r"[a-z0-9]+")
24
+
25
+
26
+ def _tokens(text: str) -> list[str]:
27
+ return _TOKEN.findall(text.lower())
28
+
29
+
30
+ class SQLiteStorage(Storage):
31
+ def __init__(self, db_path: str = ":memory:") -> None:
32
+ if db_path not in (":memory:", "") and not db_path.startswith("file:"):
33
+ parent = os.path.dirname(os.path.abspath(db_path))
34
+ os.makedirs(parent, exist_ok=True)
35
+ # check_same_thread=False + an explicit lock lets the store be shared
36
+ # across threads safely.
37
+ self._conn = sqlite3.connect(db_path, check_same_thread=False)
38
+ self._conn.row_factory = sqlite3.Row
39
+ self._lock = threading.RLock()
40
+ self._fts = self._init_schema()
41
+
42
+ # -- schema -------------------------------------------------------------
43
+
44
+ def _init_schema(self) -> bool:
45
+ with self._lock:
46
+ # WAL improves read/write concurrency and durability; NORMAL sync is
47
+ # the standard safe-and-fast pairing with WAL.
48
+ self._conn.execute("PRAGMA journal_mode=WAL")
49
+ self._conn.execute("PRAGMA synchronous=NORMAL")
50
+ self._conn.execute(
51
+ """
52
+ CREATE TABLE IF NOT EXISTS experiences (
53
+ id TEXT PRIMARY KEY,
54
+ task TEXT NOT NULL,
55
+ action TEXT NOT NULL,
56
+ outcome TEXT NOT NULL,
57
+ score REAL NOT NULL,
58
+ context TEXT NOT NULL,
59
+ embedding TEXT,
60
+ created_at TEXT NOT NULL,
61
+ superseded_by TEXT
62
+ )
63
+ """
64
+ )
65
+ self._conn.execute(
66
+ "CREATE INDEX IF NOT EXISTS idx_experiences_outcome ON experiences(outcome)"
67
+ )
68
+ self._conn.execute(
69
+ "CREATE INDEX IF NOT EXISTS idx_experiences_created ON experiences(created_at)"
70
+ )
71
+ fts_ok = True
72
+ try:
73
+ self._conn.execute(
74
+ "CREATE VIRTUAL TABLE IF NOT EXISTS experiences_fts "
75
+ "USING fts5(id UNINDEXED, task, action)"
76
+ )
77
+ except sqlite3.OperationalError:
78
+ fts_ok = False # FTS5 not compiled in — use the Python fallback
79
+ self._conn.commit()
80
+ return fts_ok
81
+
82
+ # -- writes -------------------------------------------------------------
83
+
84
+ def add(self, exp: Experience) -> None:
85
+ with self._lock:
86
+ self._conn.execute(
87
+ "INSERT OR REPLACE INTO experiences "
88
+ "(id, task, action, outcome, score, context, embedding, created_at, "
89
+ "superseded_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
90
+ (
91
+ exp.id,
92
+ exp.task,
93
+ exp.action,
94
+ exp.outcome.value,
95
+ exp.score,
96
+ json.dumps(exp.context),
97
+ json.dumps(exp.embedding) if exp.embedding is not None else None,
98
+ exp.created_at.isoformat(),
99
+ exp.superseded_by,
100
+ ),
101
+ )
102
+ if self._fts:
103
+ # Keep FTS in sync on re-record: clear any stale row for this id
104
+ # first, otherwise INSERT OR REPLACE above would leave a duplicate
105
+ # FTS entry and skew search results.
106
+ self._conn.execute("DELETE FROM experiences_fts WHERE id = ?", (exp.id,))
107
+ self._conn.execute(
108
+ "INSERT INTO experiences_fts (id, task, action) VALUES (?, ?, ?)",
109
+ (exp.id, exp.task, exp.action),
110
+ )
111
+ self._conn.commit()
112
+
113
+ def delete(self, experience_id: str) -> bool:
114
+ with self._lock:
115
+ cur = self._conn.execute(
116
+ "DELETE FROM experiences WHERE id = ?", (experience_id,)
117
+ )
118
+ if self._fts:
119
+ self._conn.execute(
120
+ "DELETE FROM experiences_fts WHERE id = ?", (experience_id,)
121
+ )
122
+ self._conn.commit()
123
+ return cur.rowcount > 0
124
+
125
+ # -- reads --------------------------------------------------------------
126
+
127
+ def get(self, experience_id: str) -> Experience | None:
128
+ with self._lock:
129
+ row = self._conn.execute(
130
+ "SELECT * FROM experiences WHERE id = ?", (experience_id,)
131
+ ).fetchone()
132
+ return self._row_to_experience(row) if row else None
133
+
134
+ def recent(self, n: int = 10) -> list[Experience]:
135
+ with self._lock:
136
+ # Tie-break on rowid (monotonic insertion order) because created_at
137
+ # resolution can collide for records written in the same instant.
138
+ rows = self._conn.execute(
139
+ "SELECT * FROM experiences ORDER BY created_at DESC, rowid DESC LIMIT ?",
140
+ (n,),
141
+ ).fetchall()
142
+ return [self._row_to_experience(r) for r in rows]
143
+
144
+ def count(self) -> int:
145
+ with self._lock:
146
+ return self._conn.execute("SELECT COUNT(*) FROM experiences").fetchone()[0]
147
+
148
+ def search(
149
+ self,
150
+ query: str,
151
+ k: int = 5,
152
+ outcome: str | None = None,
153
+ context: dict | None = None,
154
+ ) -> list[tuple[Experience, float]]:
155
+ scored = self._fts_search(query) if self._fts else self._fallback_search(query)
156
+ results: list[tuple[Experience, float]] = []
157
+ for exp, score in scored:
158
+ if outcome is not None and exp.outcome.value != outcome:
159
+ continue
160
+ if context and not _context_matches(exp.context, context):
161
+ continue
162
+ results.append((exp, score))
163
+ if len(results) >= k:
164
+ break
165
+ return results
166
+
167
+ def _fts_search(self, query: str) -> list[tuple[Experience, float]]:
168
+ terms = _tokens(query)
169
+ if not terms:
170
+ return []
171
+ match = " OR ".join(f'"{t}"' for t in terms)
172
+ with self._lock:
173
+ rows = self._conn.execute(
174
+ "SELECT e.*, bm25(experiences_fts) AS rank "
175
+ "FROM experiences_fts "
176
+ "JOIN experiences e ON e.id = experiences_fts.id "
177
+ "WHERE experiences_fts MATCH ? "
178
+ "ORDER BY rank", # bm25: lower is better
179
+ (match,),
180
+ ).fetchall()
181
+ out = []
182
+ for row in rows:
183
+ # bm25 is an unbounded negative-ish score; squash to (0, 1] for a stable API
184
+ rank = row["rank"]
185
+ score = 1.0 / (1.0 + max(rank, 0.0))
186
+ out.append((self._row_to_experience(row), score))
187
+ return out
188
+
189
+ def _fallback_search(self, query: str) -> list[tuple[Experience, float]]:
190
+ q = set(_tokens(query))
191
+ if not q:
192
+ return []
193
+ with self._lock:
194
+ rows = self._conn.execute("SELECT * FROM experiences").fetchall()
195
+ scored = []
196
+ for row in rows:
197
+ exp = self._row_to_experience(row)
198
+ doc = set(_tokens(exp.text()))
199
+ if not doc:
200
+ continue
201
+ overlap = len(q & doc)
202
+ if overlap == 0:
203
+ continue
204
+ score = overlap / len(q) # fraction of query terms matched
205
+ scored.append((exp, score))
206
+ scored.sort(key=lambda pair: pair[1], reverse=True)
207
+ return scored
208
+
209
+ # -- helpers ------------------------------------------------------------
210
+
211
+ def _row_to_experience(self, row: sqlite3.Row) -> Experience:
212
+ return Experience(
213
+ id=row["id"],
214
+ task=row["task"],
215
+ action=row["action"],
216
+ outcome=Outcome(row["outcome"]),
217
+ score=row["score"],
218
+ context=json.loads(row["context"]),
219
+ embedding=json.loads(row["embedding"]) if row["embedding"] else None,
220
+ created_at=datetime.fromisoformat(row["created_at"]),
221
+ superseded_by=row["superseded_by"],
222
+ )
223
+
224
+ def close(self) -> None:
225
+ with self._lock:
226
+ self._conn.close()
227
+
228
+
229
+ def _context_matches(stored: dict, wanted: dict) -> bool:
230
+ return all(stored.get(key) == value for key, value in wanted.items())
@@ -0,0 +1,226 @@
1
+ Metadata-Version: 2.4
2
+ Name: mimir-learn
3
+ Version: 0.1.0
4
+ Summary: Experience-driven memory for autonomous agents — learn from past successes and failures.
5
+ Project-URL: Homepage, https://github.com/AshNicolus/mimir
6
+ Project-URL: Repository, https://github.com/AshNicolus/mimir
7
+ Author: AshNicolus
8
+ License: MIT License
9
+
10
+ Copyright (c) 2026 AshNicolus
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ License-File: LICENSE
30
+ Keywords: agents,experience,learning,llm,memory
31
+ Classifier: Development Status :: 3 - Alpha
32
+ Classifier: Intended Audience :: Developers
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Programming Language :: Python :: 3.11
35
+ Classifier: Programming Language :: Python :: 3.12
36
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
37
+ Requires-Python: >=3.11
38
+ Requires-Dist: pydantic>=2.5
39
+ Provides-Extra: dev
40
+ Requires-Dist: pytest>=8.0; extra == 'dev'
41
+ Requires-Dist: ruff>=0.5; extra == 'dev'
42
+ Provides-Extra: embeddings
43
+ Requires-Dist: numpy>=1.24; extra == 'embeddings'
44
+ Requires-Dist: sentence-transformers>=2.2; extra == 'embeddings'
45
+ Description-Content-Type: text/markdown
46
+
47
+ # Mimir
48
+
49
+ **Experience-driven memory for autonomous agents.** Mimir helps agents learn from their past successes and failures instead of starting from scratch on every task.
50
+
51
+ > Named after Mímir, the keeper of wisdom in Norse mythology.
52
+
53
+ ---
54
+
55
+ ## The problem
56
+
57
+ Today's agents have memory, but they don't really *learn*.
58
+
59
+ Most frameworks store one of two things:
60
+
61
+ - **Conversation history** (LangGraph memory, buffer memory)
62
+ - **Vector embeddings of documents** (RAG, AGENTS.md, CLAUDE.md)
63
+
64
+ Both let an agent **remember information**. Neither lets it **remember experience**.
65
+
66
+ ```
67
+ Task: Fix authentication latency
68
+ Action: Added Redis cache
69
+ Outcome: Success
70
+ ```
71
+
72
+ A month later, the agent has no meaningful understanding that this strategy worked. It solves the same class of problem from zero, every time.
73
+
74
+ ## The idea
75
+
76
+ Instead of storing documents, embeddings, and metadata, Mimir stores **experiences**:
77
+
78
+ ```
79
+ Problem → Action → Outcome → Confidence → Context → Time
80
+ ```
81
+
82
+ From a stream of experiences, Mimir reflects, extracts reusable strategies, and recommends actions for new tasks — so the agent gets measurably better over time.
83
+
84
+ ```python
85
+ from mimir import Mimir
86
+
87
+ memory = Mimir()
88
+
89
+ # Record what happened
90
+ memory.record(
91
+ task="Fix authentication timeout",
92
+ action="Implemented Redis caching",
93
+ outcome="success",
94
+ score=0.95,
95
+ )
96
+
97
+ # Recall relevant past experience
98
+ past = memory.recall("authentication latency")
99
+
100
+ # Get a recommended strategy with confidence
101
+ strategy = memory.recommend("login timeout")
102
+ # → Strategy: "Redis caching" | confidence: 0.87 | based on 23 successes / 2 failures
103
+ ```
104
+
105
+ ## How it differs from AGENTS.md / CLAUDE.md
106
+
107
+ | | AGENTS.md / CLAUDE.md | Mimir |
108
+ |---|---|---|
109
+ | Knowledge type | Static, hand-written rules | Dynamic, learned from outcomes |
110
+ | Updates | Manually edited | Updates itself from results |
111
+ | Example | "Use FastAPI. Use PostgreSQL." | "Redis caching solved auth latency 23/25 times (92%)." |
112
+ | Failures | Not tracked | First-class — agents stop repeating mistakes |
113
+
114
+ AGENTS.md answers *"What should the agent remember?"*
115
+ Mimir answers *"How does an agent accumulate experience and become wiser over time?"*
116
+
117
+ ## Design principles
118
+
119
+ Mimir is built as a **modular monolith Python library** — not a microservice swarm, not a managed cloud product. The library is the product.
120
+
121
+ - **No LLM and no web server required for v1.** Storage, retrieval, and ranking come first. Reflection via an LLM is added later, behind an interface.
122
+ - **Pluggable seams.** Storage, embeddings, and the write path are interfaces, so scaling up (SQLite → Postgres → async reflection → Redis cache) is a swap, never a rewrite.
123
+ - **Derived knowledge is rebuildable.** Strategies and reflections are computed from raw experiences and can always be regenerated.
124
+ - **Failures are first-class.** Learning from what *didn't* work is treated as importantly as what did.
125
+
126
+ ## Architecture
127
+
128
+ ```
129
+ ┌────────────────────────────────────────────────────────────┐
130
+ │ Public API Mimir() .record() .recall() .recommend() │
131
+ ├────────────────────────────────────────────────────────────┤
132
+ │ Write chokepoint ──► [validation / provenance hook] │ single write path
133
+ ├──────────────┬───────────────┬─────────────────────────────┤
134
+ │ Episodic │ Reflection │ Recommendation │
135
+ │ Engine │ Engine │ Engine │
136
+ │ (record/ │ (reflect/ │ (recommend / rank / │
137
+ │ recall) │ extract) │ confidence) │
138
+ ├──────────────┴───────────────┴─────────────────────────────┤
139
+ │ Retrieval layer (keyword + optional vector hybrid) │
140
+ ├────────────────────────────────────────────────────────────┤
141
+ │ Storage interface SQLite (v1) · Postgres (v2) · … │ pluggable
142
+ ├────────────────────────────────────────────────────────────┤
143
+ │ Embedding provider none (default) · local · API │ pluggable
144
+ └────────────────────────────────────────────────────────────┘
145
+ ```
146
+
147
+ ### Data model
148
+
149
+ ```
150
+ Experience
151
+ id, task, action, outcome (success|failure|partial),
152
+ score (0..1), context (json), embedding (nullable),
153
+ created_at, superseded_by (nullable)
154
+
155
+ Strategy (derived) problem_pattern, recommended_action, confidence,
156
+ success_count, failure_count, source_experience_ids
157
+ Reflection (derived) summary, pattern, supporting_experience_ids, created_at
158
+ ```
159
+
160
+ ## Installation
161
+
162
+ ```bash
163
+ pip install mimir # (coming soon)
164
+
165
+ # or, for development
166
+ git clone https://github.com/<you>/mimir.git
167
+ cd mimir
168
+ pip install -e ".[dev]"
169
+ ```
170
+
171
+ **Requirements:** Python 3.11+. v1 has no required external services — storage is a local SQLite file. Semantic search and reflection are optional extras.
172
+
173
+ ## Quick start
174
+
175
+ ```python
176
+ from mimir import Mimir
177
+
178
+ memory = Mimir(db_path="mimir.db")
179
+
180
+ memory.record(
181
+ task="Fix login latency",
182
+ action="Added Redis cache in front of session lookups",
183
+ outcome="success",
184
+ score=0.9,
185
+ context={"service": "auth", "language": "python"},
186
+ )
187
+
188
+ memory.record_failure(
189
+ task="Throttle abusive clients",
190
+ action="Added a fixed-window rate limiter",
191
+ reason="WebSocket traffic wasn't handled — limiter only saw HTTP",
192
+ )
193
+
194
+ for exp in memory.recall("authentication is slow", k=5):
195
+ print(exp.action, exp.outcome, exp.score)
196
+
197
+ print(memory.recommend("login times out under load"))
198
+ ```
199
+
200
+ ## Roadmap
201
+
202
+ | Phase | Goal | Status |
203
+ |---|---|---|
204
+ | **1 — Episodic memory** | `record()` / `recall()`, outcome tracking, SQLite backend | 🛠️ In progress |
205
+ | **2 — Failure memory** | `record_failure()`, failures queried separately | Planned |
206
+ | **3 — Reflection engine** | `reflect()` — cluster experiences, synthesize patterns (LLM) | Planned |
207
+ | **4 — Strategy extraction** | Turn experiences into reusable strategies with confidence | Planned |
208
+ | **5 — Recommendation engine** | `recommend()` — rank strategies for a new task | Planned |
209
+ | **6 — Shared org memory** | Multiple agents learn from a shared store | Future |
210
+
211
+ ## Scaling path
212
+
213
+ Mimir starts as a single SQLite file and grows by swapping seams — no rewrites:
214
+
215
+ 1. **v1** — SQLite, in-process, single agent.
216
+ 2. **v2** — Postgres + pgvector backend for concurrent multi-agent writes.
217
+ 3. **v3** — extract the (slow, batch) reflection engine into an async worker.
218
+ 4. **v4** — Redis cache for hot/recent experiences on the read path.
219
+
220
+ ## Status
221
+
222
+ Early development. APIs will change. Not yet published to PyPI. Feedback and ideas welcome.
223
+
224
+ ## License
225
+
226
+ MIT
@@ -0,0 +1,11 @@
1
+ mimir/__init__.py,sha256=hnxZwiW7O66Irie92SOHgQmrETq0qbzI_iBDpIhoq84,414
2
+ mimir/core.py,sha256=7qRzgdzVYJ7HywydvLvwnazpzxiQzz15zGLU9XqmR1w,7497
3
+ mimir/embeddings.py,sha256=pbEifuCYUKCvphSfSGv28_Qr0ndcCfJmM99u2YZHeQA,1094
4
+ mimir/models.py,sha256=9o6oDc8gap6x3iiHUZ7auv8HIRG-UXjF4iKGf7IVrDA,2878
5
+ mimir/storage/__init__.py,sha256=FkN7CtMvJIUTNMbVLD_LIAVqqTfKQDk5bQTTfAOKrRQ,100
6
+ mimir/storage/base.py,sha256=xmyiNiqc1gA6kX6iNzqQrbZJdXTdMmg0IO3iT7ENMKQ,1553
7
+ mimir/storage/sqlite.py,sha256=uubY4BpuvPWa-vVbITUhRTiSCt_lJfmzPk2vqIDAu_s,8895
8
+ mimir_learn-0.1.0.dist-info/METADATA,sha256=nUciuXZVQsx-o_hwi4naYA9I_5eW88qTZ7W0UOvekB4,9716
9
+ mimir_learn-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
10
+ mimir_learn-0.1.0.dist-info/licenses/LICENSE,sha256=BeqLZ3TPJtSjsBWSUtp9a_OLBnlQP111ATLC_JCxXG4,1067
11
+ mimir_learn-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AshNicolus
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.