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.
@@ -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,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,4 @@
1
+ from mneme.schema import Tag, Turn, Event
2
+ from mneme.memory import Memory
3
+
4
+ __all__ = ["Memory", "Tag", "Turn", "Event"]
@@ -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,9 @@
1
+ spacy>=3.7
2
+
3
+ [eval]
4
+ mem0ai
5
+ sentence-transformers
6
+ matplotlib
7
+
8
+ [zh]
9
+ jieba
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+