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 +19 -0
- mimir/core.py +201 -0
- mimir/embeddings.py +39 -0
- mimir/models.py +89 -0
- mimir/storage/__init__.py +4 -0
- mimir/storage/base.py +52 -0
- mimir/storage/sqlite.py +230 -0
- mimir_learn-0.1.0.dist-info/METADATA +226 -0
- mimir_learn-0.1.0.dist-info/RECORD +11 -0
- mimir_learn-0.1.0.dist-info/WHEEL +4 -0
- mimir_learn-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
)
|
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."""
|
mimir/storage/sqlite.py
ADDED
|
@@ -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,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.
|