mnemekit 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.
- mnemekit-0.1.0/PKG-INFO +70 -0
- mnemekit-0.1.0/README.md +49 -0
- mnemekit-0.1.0/mneme/__init__.py +4 -0
- mnemekit-0.1.0/mneme/extract.py +92 -0
- mnemekit-0.1.0/mneme/memory.py +61 -0
- mnemekit-0.1.0/mneme/recall.py +62 -0
- mnemekit-0.1.0/mneme/schema.py +42 -0
- mnemekit-0.1.0/mneme/store.py +63 -0
- mnemekit-0.1.0/mneme/thread.py +44 -0
- mnemekit-0.1.0/mnemekit.egg-info/PKG-INFO +70 -0
- mnemekit-0.1.0/mnemekit.egg-info/SOURCES.txt +14 -0
- mnemekit-0.1.0/mnemekit.egg-info/dependency_links.txt +1 -0
- mnemekit-0.1.0/mnemekit.egg-info/requires.txt +9 -0
- mnemekit-0.1.0/mnemekit.egg-info/top_level.txt +1 -0
- mnemekit-0.1.0/pyproject.toml +29 -0
- mnemekit-0.1.0/setup.cfg +4 -0
mnemekit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mnemekit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A zero-LLM, zero-embedding, cue-indexed tag memory for conversational agents.
|
|
5
|
+
Author: Yam
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/FTP2026/Mneme
|
|
8
|
+
Keywords: memory,llm,agent,conversational,retrieval,spreading-activation
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: spacy>=3.7
|
|
15
|
+
Provides-Extra: zh
|
|
16
|
+
Requires-Dist: jieba; extra == "zh"
|
|
17
|
+
Provides-Extra: eval
|
|
18
|
+
Requires-Dist: mem0ai; extra == "eval"
|
|
19
|
+
Requires-Dist: sentence-transformers; extra == "eval"
|
|
20
|
+
Requires-Dist: matplotlib; extra == "eval"
|
|
21
|
+
|
|
22
|
+
# Mneme
|
|
23
|
+
|
|
24
|
+
A local, cue-indexed **tag memory** for conversational agents — **no embeddings, no LLM** in the memory path.
|
|
25
|
+
|
|
26
|
+
Human recall is cue-driven: a partial cue brings back a cluster of related memories that the mind wires into a momentary web and then lets dissolve. Mneme models this. Each turn is tagged deterministically; tags form an inverted index (the cue index); a query reinstates directly-cued turns lexically and then builds an **ephemeral association graph** over them—spreading activation to related turns—which is discarded once the query is answered. No vector DB, no graph DB, no language model.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install mnemekit # English (default)
|
|
32
|
+
python -m spacy download en_core_web_sm # required language model
|
|
33
|
+
|
|
34
|
+
pip install "mnemekit[zh]" # + Chinese (adds jieba)
|
|
35
|
+
pip install "mnemekit[eval]" # + benchmark/baseline deps
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`import mneme` regardless of the distribution name.
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from mneme import Memory
|
|
44
|
+
|
|
45
|
+
m = Memory(root="~/.mneme")
|
|
46
|
+
m.remember("My dog Lucky is a golden retriever", "Cute! How old is Lucky?",
|
|
47
|
+
session_id="chat-42", round_id=1)
|
|
48
|
+
m.recall("what breed is my dog") # -> [Turn, ...]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- **Structured ids & storage.** `session_id` (default `"default-session"`) + `round_id` form each turn's id; turns are stored one file per round under `{root}/{session_id}/{YYYY-MM-DD}/{round_id}.json`, so a session is easy to inspect, export, or delete.
|
|
52
|
+
- **Chinese.** With `[zh]` installed, Chinese is segmented by `jieba`; pass a custom dictionary for vertical domains: `Memory(userdict="terms.txt")`.
|
|
53
|
+
- **Optional LLM enrichment.** An `enrich(text, source, tags) -> tags` callback can add semantic tags; off by default (the memory path stays LLM-free).
|
|
54
|
+
|
|
55
|
+
## Design
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
schema.py — Tag / Turn / Event records
|
|
59
|
+
extract.py — TagExtractor: spaCy NER+lemmas (en) / jieba.posseg (zh, +userdict)
|
|
60
|
+
thread.py — Threader: event segmentation + tag inheritance for keyword-free follow-ups
|
|
61
|
+
store.py — Store: per-session/day turn files + inverted.json + events.json
|
|
62
|
+
recall.py — Recaller: lexical IDF reinstatement + ephemeral association graph
|
|
63
|
+
memory.py — Memory: remember() / recall()
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Two-stage recall: (1) lexical, IDF-weighted lookup over the inverted index; (2) a per-query association graph seeded by the lexical hits that spreads activation through shared entities and is then discarded. Because information without distinctive cues is never surfaced, forgetting is implicit and cue-dependent.
|
|
67
|
+
|
|
68
|
+
## Evaluation & paper
|
|
69
|
+
|
|
70
|
+
See `paper/` for the write-up and `eval/` for the LoCoMo / LongMemEval harness and the controlled mem0 comparison. The frozen reproduction version is on the `paper-repro` branch.
|
mnemekit-0.1.0/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Mneme
|
|
2
|
+
|
|
3
|
+
A local, cue-indexed **tag memory** for conversational agents — **no embeddings, no LLM** in the memory path.
|
|
4
|
+
|
|
5
|
+
Human recall is cue-driven: a partial cue brings back a cluster of related memories that the mind wires into a momentary web and then lets dissolve. Mneme models this. Each turn is tagged deterministically; tags form an inverted index (the cue index); a query reinstates directly-cued turns lexically and then builds an **ephemeral association graph** over them—spreading activation to related turns—which is discarded once the query is answered. No vector DB, no graph DB, no language model.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install mnemekit # English (default)
|
|
11
|
+
python -m spacy download en_core_web_sm # required language model
|
|
12
|
+
|
|
13
|
+
pip install "mnemekit[zh]" # + Chinese (adds jieba)
|
|
14
|
+
pip install "mnemekit[eval]" # + benchmark/baseline deps
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`import mneme` regardless of the distribution name.
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from mneme import Memory
|
|
23
|
+
|
|
24
|
+
m = Memory(root="~/.mneme")
|
|
25
|
+
m.remember("My dog Lucky is a golden retriever", "Cute! How old is Lucky?",
|
|
26
|
+
session_id="chat-42", round_id=1)
|
|
27
|
+
m.recall("what breed is my dog") # -> [Turn, ...]
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- **Structured ids & storage.** `session_id` (default `"default-session"`) + `round_id` form each turn's id; turns are stored one file per round under `{root}/{session_id}/{YYYY-MM-DD}/{round_id}.json`, so a session is easy to inspect, export, or delete.
|
|
31
|
+
- **Chinese.** With `[zh]` installed, Chinese is segmented by `jieba`; pass a custom dictionary for vertical domains: `Memory(userdict="terms.txt")`.
|
|
32
|
+
- **Optional LLM enrichment.** An `enrich(text, source, tags) -> tags` callback can add semantic tags; off by default (the memory path stays LLM-free).
|
|
33
|
+
|
|
34
|
+
## Design
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
schema.py — Tag / Turn / Event records
|
|
38
|
+
extract.py — TagExtractor: spaCy NER+lemmas (en) / jieba.posseg (zh, +userdict)
|
|
39
|
+
thread.py — Threader: event segmentation + tag inheritance for keyword-free follow-ups
|
|
40
|
+
store.py — Store: per-session/day turn files + inverted.json + events.json
|
|
41
|
+
recall.py — Recaller: lexical IDF reinstatement + ephemeral association graph
|
|
42
|
+
memory.py — Memory: remember() / recall()
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Two-stage recall: (1) lexical, IDF-weighted lookup over the inverted index; (2) a per-query association graph seeded by the lexical hits that spreads activation through shared entities and is then discarded. Because information without distinctive cues is never surfaced, forgetting is implicit and cue-dependent.
|
|
46
|
+
|
|
47
|
+
## Evaluation & paper
|
|
48
|
+
|
|
49
|
+
See `paper/` for the write-up and `eval/` for the LoCoMo / LongMemEval harness and the controlled mem0 comparison. The frozen reproduction version is on the `paper-repro` branch.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from functools import lru_cache
|
|
2
|
+
from typing import Callable, Optional
|
|
3
|
+
|
|
4
|
+
import spacy
|
|
5
|
+
|
|
6
|
+
from mneme.schema import Tag, TagSource
|
|
7
|
+
|
|
8
|
+
Enricher = Callable[[str, TagSource, list[Tag]], list[Tag]]
|
|
9
|
+
|
|
10
|
+
EN_MODEL = "en_core_web_sm"
|
|
11
|
+
CONTENT_POS = {"NOUN", "PROPN", "VERB", "ADJ"}
|
|
12
|
+
SKIP_LABELS = {"DATE", "TIME", "CARDINAL", "ORDINAL", "QUANTITY", "PERCENT"}
|
|
13
|
+
ZH_ENT_FLAGS = {"nr", "ns", "nt", "nz", "eng"}
|
|
14
|
+
ZH_CONTENT_HEADS = ("n", "v", "a")
|
|
15
|
+
ENTITY_WEIGHT = 2.0
|
|
16
|
+
TERM_WEIGHT = 1.0
|
|
17
|
+
CONT_MAX_LEN = 6
|
|
18
|
+
CONT_MARKERS = ("接着", "然后", "继续", "还有", "对了", "顺便", "另外", "所以呢", "后来")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@lru_cache(maxsize=2)
|
|
22
|
+
def _en():
|
|
23
|
+
return spacy.load(EN_MODEL, disable=["parser"]) if spacy.util.is_package(EN_MODEL) else None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TagExtractor:
|
|
27
|
+
|
|
28
|
+
def __init__(self, enrich: Optional[Enricher] = None, userdict: Optional[str] = None):
|
|
29
|
+
self._enrich = enrich
|
|
30
|
+
self._userdict = userdict
|
|
31
|
+
self._pseg = None
|
|
32
|
+
|
|
33
|
+
def __call__(self, text: str, source: TagSource) -> list[Tag]:
|
|
34
|
+
if not text.strip():
|
|
35
|
+
return []
|
|
36
|
+
tags = self._zh_tags(text, source) if self._has_cjk(text) else self._en_tags(text, source)
|
|
37
|
+
if self._enrich:
|
|
38
|
+
tags = self._enrich(text, source, tags)
|
|
39
|
+
return tags
|
|
40
|
+
|
|
41
|
+
def is_continuation(self, text: str) -> bool:
|
|
42
|
+
t = text.strip()
|
|
43
|
+
return t.startswith(CONT_MARKERS) or len(t) <= CONT_MAX_LEN
|
|
44
|
+
|
|
45
|
+
def _en_tags(self, text: str, source: TagSource) -> list[Tag]:
|
|
46
|
+
nlp = _en()
|
|
47
|
+
if nlp is None:
|
|
48
|
+
return []
|
|
49
|
+
best: dict[str, Tag] = {}
|
|
50
|
+
doc = nlp(text)
|
|
51
|
+
for ent in doc.ents:
|
|
52
|
+
if ent.label_ not in SKIP_LABELS:
|
|
53
|
+
self._put(best, ent.text, source, "entity", ENTITY_WEIGHT)
|
|
54
|
+
for tok in doc:
|
|
55
|
+
if tok.pos_ in CONTENT_POS and not tok.is_stop and not tok.like_num:
|
|
56
|
+
self._put(best, tok.lemma_ or tok.text, source, "term", TERM_WEIGHT)
|
|
57
|
+
return list(best.values())
|
|
58
|
+
|
|
59
|
+
def _zh_tags(self, text: str, source: TagSource) -> list[Tag]:
|
|
60
|
+
pseg = self._zh_seg()
|
|
61
|
+
if pseg is None:
|
|
62
|
+
return self._en_tags(text, source)
|
|
63
|
+
best: dict[str, Tag] = {}
|
|
64
|
+
for w in pseg.cut(text):
|
|
65
|
+
if w.flag in ZH_ENT_FLAGS:
|
|
66
|
+
self._put(best, w.word, source, "entity", ENTITY_WEIGHT)
|
|
67
|
+
elif w.flag and w.flag[0] in ZH_CONTENT_HEADS:
|
|
68
|
+
self._put(best, w.word, source, "term", TERM_WEIGHT)
|
|
69
|
+
return list(best.values())
|
|
70
|
+
|
|
71
|
+
def _zh_seg(self):
|
|
72
|
+
if self._pseg is None:
|
|
73
|
+
try:
|
|
74
|
+
import jieba
|
|
75
|
+
import jieba.posseg as pseg
|
|
76
|
+
except ImportError:
|
|
77
|
+
return None
|
|
78
|
+
if self._userdict:
|
|
79
|
+
jieba.load_userdict(self._userdict)
|
|
80
|
+
self._pseg = pseg
|
|
81
|
+
return self._pseg
|
|
82
|
+
|
|
83
|
+
def _put(self, best: dict, raw: str, source: TagSource, kind: str, weight: float) -> None:
|
|
84
|
+
text = raw.lower().strip()
|
|
85
|
+
if len(text) < 2:
|
|
86
|
+
return
|
|
87
|
+
cur = best.get(text)
|
|
88
|
+
if cur is None or weight > cur.weight:
|
|
89
|
+
best[text] = Tag(text, source, kind, weight)
|
|
90
|
+
|
|
91
|
+
def _has_cjk(self, text: str) -> bool:
|
|
92
|
+
return any("一" <= c <= "鿿" for c in text)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from mneme.schema import Turn, Event
|
|
6
|
+
from mneme.extract import TagExtractor, Enricher
|
|
7
|
+
from mneme.thread import Threader
|
|
8
|
+
from mneme.store import Store
|
|
9
|
+
from mneme.recall import Recaller
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Memory:
|
|
13
|
+
|
|
14
|
+
def __init__(self, root: str = "~/.mneme", enrich: Optional[Enricher] = None, associate: bool = True, userdict: Optional[str] = None):
|
|
15
|
+
self.store = Store(os.path.expanduser(root))
|
|
16
|
+
self.extract = TagExtractor(enrich=enrich, userdict=userdict)
|
|
17
|
+
self.thread = Threader(self.store.last_event())
|
|
18
|
+
self._recaller = Recaller(self.store, self.extract, associate=associate)
|
|
19
|
+
|
|
20
|
+
def remember(
|
|
21
|
+
self,
|
|
22
|
+
query: str,
|
|
23
|
+
response: str,
|
|
24
|
+
*,
|
|
25
|
+
session_id: str = "default-session",
|
|
26
|
+
round_id: Optional[int] = None,
|
|
27
|
+
ts: Optional[float] = None,
|
|
28
|
+
event_id: Optional[str] = None,
|
|
29
|
+
) -> Turn:
|
|
30
|
+
ts = ts if ts is not None else time.time()
|
|
31
|
+
tags = self.extract(query, "user") + self.extract(response, "assistant")
|
|
32
|
+
if event_id is not None:
|
|
33
|
+
event, carried = self._fixed_event(event_id, tags, ts)
|
|
34
|
+
else:
|
|
35
|
+
event, carried = self.thread.assign(tags, self.extract.is_continuation(query), ts)
|
|
36
|
+
rid = round_id if round_id is not None else int(time.time() * 1e6)
|
|
37
|
+
turn = Turn(
|
|
38
|
+
id=f"{session_id}:{rid}",
|
|
39
|
+
ts=ts,
|
|
40
|
+
query=query,
|
|
41
|
+
response=response,
|
|
42
|
+
event_id=event.id,
|
|
43
|
+
tags=carried,
|
|
44
|
+
)
|
|
45
|
+
event.turn_ids.append(turn.id)
|
|
46
|
+
self.store.add_turn(turn)
|
|
47
|
+
self.store.put_event(event)
|
|
48
|
+
return turn
|
|
49
|
+
|
|
50
|
+
def _fixed_event(self, event_id: str, tags: list, ts: float) -> tuple:
|
|
51
|
+
texts = {t.text for t in tags}
|
|
52
|
+
if event := self.store.events.get(event_id):
|
|
53
|
+
event.tags = sorted(set(event.tags) | texts)
|
|
54
|
+
return event, tags
|
|
55
|
+
return Event(id=event_id, ts=ts, tier="STM", tags=sorted(texts), turn_ids=[]), tags
|
|
56
|
+
|
|
57
|
+
def recall(self, query: str, topn: int = 5, assoc_n: int = 0) -> list[Turn]:
|
|
58
|
+
return self._recaller(query, topn, assoc_n)
|
|
59
|
+
|
|
60
|
+
def confidence(self, query: str) -> float:
|
|
61
|
+
return self._recaller.confidence(query)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from collections import defaultdict
|
|
3
|
+
|
|
4
|
+
from mneme.schema import Tag, Turn
|
|
5
|
+
from mneme.extract import TagExtractor
|
|
6
|
+
from mneme.store import Store
|
|
7
|
+
|
|
8
|
+
SEED_K = 15
|
|
9
|
+
EXPAND_DF_MAX = 20
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Recaller:
|
|
13
|
+
|
|
14
|
+
def __init__(self, store: Store, extractor: TagExtractor, associate: bool = True):
|
|
15
|
+
self.store = store
|
|
16
|
+
self.extract = extractor
|
|
17
|
+
self.associate = associate
|
|
18
|
+
|
|
19
|
+
def __call__(self, query: str, topn: int = 5, assoc_n: int = 0) -> list[Turn]:
|
|
20
|
+
seeds = self.extract(query, "user")
|
|
21
|
+
if not seeds:
|
|
22
|
+
return []
|
|
23
|
+
score = self._lexical(seeds)
|
|
24
|
+
lex = sorted(score, key=score.get, reverse=True)[:topn]
|
|
25
|
+
if not self.associate or assoc_n <= 0:
|
|
26
|
+
return [self.store.turns[tid] for tid in lex]
|
|
27
|
+
extra = self._associate(score, lex, set(lex), assoc_n)
|
|
28
|
+
return [self.store.turns[tid] for tid in lex + extra]
|
|
29
|
+
|
|
30
|
+
def confidence(self, query: str) -> float:
|
|
31
|
+
seeds = self.extract(query, "user")
|
|
32
|
+
if not seeds:
|
|
33
|
+
return 0.0
|
|
34
|
+
score = self._lexical(seeds)
|
|
35
|
+
return max(score.values()) if score else 0.0
|
|
36
|
+
|
|
37
|
+
def _lexical(self, seeds: list[Tag]) -> dict[str, float]:
|
|
38
|
+
score: dict[str, float] = defaultdict(float)
|
|
39
|
+
for t in seeds:
|
|
40
|
+
idf = self._idf(t.text)
|
|
41
|
+
for tid in self.store.turns_for(t.text):
|
|
42
|
+
score[tid] += t.weight * idf
|
|
43
|
+
return score
|
|
44
|
+
|
|
45
|
+
def _associate(self, score: dict[str, float], lex: list[str], seen: set[str], need: int) -> list[str]:
|
|
46
|
+
strength: dict[str, float] = defaultdict(float)
|
|
47
|
+
for tid in lex[:SEED_K]:
|
|
48
|
+
base = score[tid]
|
|
49
|
+
for tag in self.store.turns[tid].tags:
|
|
50
|
+
sibs = self.store.turns_for(tag.text)
|
|
51
|
+
if len(sibs) <= 1 or len(sibs) > EXPAND_DF_MAX:
|
|
52
|
+
continue
|
|
53
|
+
bridge = base * self._idf(tag.text)
|
|
54
|
+
for nb in sibs:
|
|
55
|
+
if nb not in seen:
|
|
56
|
+
strength[nb] += bridge
|
|
57
|
+
return sorted(strength, key=strength.get, reverse=True)[:need]
|
|
58
|
+
|
|
59
|
+
def _idf(self, tag: str) -> float:
|
|
60
|
+
n = len(self.store.turns)
|
|
61
|
+
df = len(self.store.turns_for(tag))
|
|
62
|
+
return math.log(1 + n / df) if df else 0.0
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from dataclasses import dataclass, asdict
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
TagKind = Literal["entity", "term", "time", "emotion"]
|
|
5
|
+
TagSource = Literal["user", "assistant", "event"]
|
|
6
|
+
Tier = Literal["STM", "MTM", "LTM"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Tag:
|
|
11
|
+
text: str
|
|
12
|
+
source: TagSource
|
|
13
|
+
kind: TagKind
|
|
14
|
+
weight: float
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Turn:
|
|
19
|
+
id: str
|
|
20
|
+
ts: float
|
|
21
|
+
query: str
|
|
22
|
+
response: str
|
|
23
|
+
event_id: str
|
|
24
|
+
tags: list[Tag]
|
|
25
|
+
|
|
26
|
+
def as_dict(self) -> dict:
|
|
27
|
+
return asdict(self)
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_dict(cls, d: dict) -> "Turn":
|
|
31
|
+
d = dict(d)
|
|
32
|
+
d["tags"] = [Tag(**t) for t in d["tags"]]
|
|
33
|
+
return cls(**d)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Event:
|
|
38
|
+
id: str
|
|
39
|
+
ts: float
|
|
40
|
+
tier: Tier
|
|
41
|
+
tags: list[str]
|
|
42
|
+
turn_ids: list[str]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
from glob import glob
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from mneme.schema import Turn, Event
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Store:
|
|
12
|
+
|
|
13
|
+
def __init__(self, root: str):
|
|
14
|
+
self.root = root
|
|
15
|
+
self._events_path = os.path.join(root, "events.json")
|
|
16
|
+
self._index_path = os.path.join(root, "inverted.json")
|
|
17
|
+
self.turns: dict[str, Turn] = {}
|
|
18
|
+
self.events: dict[str, Event] = {}
|
|
19
|
+
self.index: dict[str, list[str]] = {}
|
|
20
|
+
self._load()
|
|
21
|
+
|
|
22
|
+
def add_turn(self, turn: Turn) -> None:
|
|
23
|
+
self.turns[turn.id] = turn
|
|
24
|
+
for t in turn.tags:
|
|
25
|
+
ids = self.index.setdefault(t.text, [])
|
|
26
|
+
if turn.id not in ids:
|
|
27
|
+
ids.append(turn.id)
|
|
28
|
+
session, _, rnd = turn.id.partition(":")
|
|
29
|
+
day = datetime.fromtimestamp(turn.ts).strftime("%Y-%m-%d")
|
|
30
|
+
d = os.path.join(self.root, session, day)
|
|
31
|
+
os.makedirs(d, exist_ok=True)
|
|
32
|
+
with open(os.path.join(d, f"{rnd}.json"), "w") as f:
|
|
33
|
+
json.dump(turn.as_dict(), f, ensure_ascii=False)
|
|
34
|
+
self._dump(self._index_path, self.index)
|
|
35
|
+
|
|
36
|
+
def put_event(self, event: Event) -> None:
|
|
37
|
+
self.events[event.id] = event
|
|
38
|
+
self._dump(self._events_path, {k: asdict(v) for k, v in self.events.items()})
|
|
39
|
+
|
|
40
|
+
def turns_for(self, tag: str) -> list[str]:
|
|
41
|
+
return self.index.get(tag, [])
|
|
42
|
+
|
|
43
|
+
def last_event(self) -> Optional[Event]:
|
|
44
|
+
if not self.events:
|
|
45
|
+
return None
|
|
46
|
+
return max(self.events.values(), key=lambda e: e.ts)
|
|
47
|
+
|
|
48
|
+
def _load(self) -> None:
|
|
49
|
+
os.makedirs(self.root, exist_ok=True)
|
|
50
|
+
for path in sorted(glob(os.path.join(self.root, "*", "*", "*.json"))):
|
|
51
|
+
with open(path) as f:
|
|
52
|
+
turn = Turn.from_dict(json.load(f))
|
|
53
|
+
self.turns[turn.id] = turn
|
|
54
|
+
if os.path.exists(self._events_path):
|
|
55
|
+
with open(self._events_path) as f:
|
|
56
|
+
self.events = {k: Event(**v) for k, v in json.load(f).items()}
|
|
57
|
+
if os.path.exists(self._index_path):
|
|
58
|
+
with open(self._index_path) as f:
|
|
59
|
+
self.index = json.load(f)
|
|
60
|
+
|
|
61
|
+
def _dump(self, path: str, obj) -> None:
|
|
62
|
+
with open(path, "w") as f:
|
|
63
|
+
json.dump(obj, f, ensure_ascii=False)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from mneme.schema import Tag, Event
|
|
4
|
+
|
|
5
|
+
JACCARD_TH = 0.15
|
|
6
|
+
GAP_SEC = 30 * 60
|
|
7
|
+
INHERIT_WEIGHT = 0.5
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Threader:
|
|
11
|
+
|
|
12
|
+
def __init__(self, active: Optional[Event] = None):
|
|
13
|
+
self._event = active
|
|
14
|
+
|
|
15
|
+
def assign(self, tags: list[Tag], is_cont: bool, ts: float) -> tuple[Event, list[Tag]]:
|
|
16
|
+
texts = {t.text for t in tags}
|
|
17
|
+
if self._same_event(texts, is_cont, ts):
|
|
18
|
+
carried = self._inherit(tags)
|
|
19
|
+
self._event.tags = sorted(set(self._event.tags) | texts)
|
|
20
|
+
self._event.ts = ts
|
|
21
|
+
return self._event, carried
|
|
22
|
+
ev = Event(id=f"e{int(ts * 1e6)}", ts=ts, tier="STM", tags=sorted(texts), turn_ids=[])
|
|
23
|
+
self._event = ev
|
|
24
|
+
return ev, tags
|
|
25
|
+
|
|
26
|
+
def _same_event(self, texts: set[str], is_cont: bool, ts: float) -> bool:
|
|
27
|
+
if self._event is None or ts - self._event.ts > GAP_SEC:
|
|
28
|
+
return False
|
|
29
|
+
if is_cont:
|
|
30
|
+
return True
|
|
31
|
+
return self._jaccard(texts, set(self._event.tags)) >= JACCARD_TH
|
|
32
|
+
|
|
33
|
+
def _inherit(self, tags: list[Tag]) -> list[Tag]:
|
|
34
|
+
have = {t.text for t in tags}
|
|
35
|
+
carried = list(tags)
|
|
36
|
+
for txt in self._event.tags:
|
|
37
|
+
if txt not in have:
|
|
38
|
+
carried.append(Tag(txt, "event", "term", INHERIT_WEIGHT))
|
|
39
|
+
return carried
|
|
40
|
+
|
|
41
|
+
def _jaccard(self, a: set[str], b: set[str]) -> float:
|
|
42
|
+
if not a or not b:
|
|
43
|
+
return 0.0
|
|
44
|
+
return len(a & b) / len(a | b)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mnemekit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A zero-LLM, zero-embedding, cue-indexed tag memory for conversational agents.
|
|
5
|
+
Author: Yam
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/FTP2026/Mneme
|
|
8
|
+
Keywords: memory,llm,agent,conversational,retrieval,spreading-activation
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: spacy>=3.7
|
|
15
|
+
Provides-Extra: zh
|
|
16
|
+
Requires-Dist: jieba; extra == "zh"
|
|
17
|
+
Provides-Extra: eval
|
|
18
|
+
Requires-Dist: mem0ai; extra == "eval"
|
|
19
|
+
Requires-Dist: sentence-transformers; extra == "eval"
|
|
20
|
+
Requires-Dist: matplotlib; extra == "eval"
|
|
21
|
+
|
|
22
|
+
# Mneme
|
|
23
|
+
|
|
24
|
+
A local, cue-indexed **tag memory** for conversational agents — **no embeddings, no LLM** in the memory path.
|
|
25
|
+
|
|
26
|
+
Human recall is cue-driven: a partial cue brings back a cluster of related memories that the mind wires into a momentary web and then lets dissolve. Mneme models this. Each turn is tagged deterministically; tags form an inverted index (the cue index); a query reinstates directly-cued turns lexically and then builds an **ephemeral association graph** over them—spreading activation to related turns—which is discarded once the query is answered. No vector DB, no graph DB, no language model.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install mnemekit # English (default)
|
|
32
|
+
python -m spacy download en_core_web_sm # required language model
|
|
33
|
+
|
|
34
|
+
pip install "mnemekit[zh]" # + Chinese (adds jieba)
|
|
35
|
+
pip install "mnemekit[eval]" # + benchmark/baseline deps
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`import mneme` regardless of the distribution name.
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from mneme import Memory
|
|
44
|
+
|
|
45
|
+
m = Memory(root="~/.mneme")
|
|
46
|
+
m.remember("My dog Lucky is a golden retriever", "Cute! How old is Lucky?",
|
|
47
|
+
session_id="chat-42", round_id=1)
|
|
48
|
+
m.recall("what breed is my dog") # -> [Turn, ...]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- **Structured ids & storage.** `session_id` (default `"default-session"`) + `round_id` form each turn's id; turns are stored one file per round under `{root}/{session_id}/{YYYY-MM-DD}/{round_id}.json`, so a session is easy to inspect, export, or delete.
|
|
52
|
+
- **Chinese.** With `[zh]` installed, Chinese is segmented by `jieba`; pass a custom dictionary for vertical domains: `Memory(userdict="terms.txt")`.
|
|
53
|
+
- **Optional LLM enrichment.** An `enrich(text, source, tags) -> tags` callback can add semantic tags; off by default (the memory path stays LLM-free).
|
|
54
|
+
|
|
55
|
+
## Design
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
schema.py — Tag / Turn / Event records
|
|
59
|
+
extract.py — TagExtractor: spaCy NER+lemmas (en) / jieba.posseg (zh, +userdict)
|
|
60
|
+
thread.py — Threader: event segmentation + tag inheritance for keyword-free follow-ups
|
|
61
|
+
store.py — Store: per-session/day turn files + inverted.json + events.json
|
|
62
|
+
recall.py — Recaller: lexical IDF reinstatement + ephemeral association graph
|
|
63
|
+
memory.py — Memory: remember() / recall()
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Two-stage recall: (1) lexical, IDF-weighted lookup over the inverted index; (2) a per-query association graph seeded by the lexical hits that spreads activation through shared entities and is then discarded. Because information without distinctive cues is never surfaced, forgetting is implicit and cue-dependent.
|
|
67
|
+
|
|
68
|
+
## Evaluation & paper
|
|
69
|
+
|
|
70
|
+
See `paper/` for the write-up and `eval/` for the LoCoMo / LongMemEval harness and the controlled mem0 comparison. The frozen reproduction version is on the `paper-repro` branch.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
mneme/__init__.py
|
|
4
|
+
mneme/extract.py
|
|
5
|
+
mneme/memory.py
|
|
6
|
+
mneme/recall.py
|
|
7
|
+
mneme/schema.py
|
|
8
|
+
mneme/store.py
|
|
9
|
+
mneme/thread.py
|
|
10
|
+
mnemekit.egg-info/PKG-INFO
|
|
11
|
+
mnemekit.egg-info/SOURCES.txt
|
|
12
|
+
mnemekit.egg-info/dependency_links.txt
|
|
13
|
+
mnemekit.egg-info/requires.txt
|
|
14
|
+
mnemekit.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mneme
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mnemekit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A zero-LLM, zero-embedding, cue-indexed tag memory for conversational agents."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Yam" }]
|
|
13
|
+
keywords = ["memory", "llm", "agent", "conversational", "retrieval", "spreading-activation"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
18
|
+
]
|
|
19
|
+
dependencies = ["spacy>=3.7"]
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
zh = ["jieba"]
|
|
23
|
+
eval = ["mem0ai", "sentence-transformers", "matplotlib"]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/FTP2026/Mneme"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools]
|
|
29
|
+
packages = ["mneme"]
|
mnemekit-0.1.0/setup.cfg
ADDED