etchmem 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.
etchmem/engine.py ADDED
@@ -0,0 +1,191 @@
1
+ """
2
+ Engine — the public entry point.
3
+
4
+ Wires together:
5
+ - three ChromaStore instances (relational, buffer, injected)
6
+ - one RemoteConnector for LLM synthesis
7
+ - Recaller (recall logic + recall-event emission)
8
+ - Worker (consolidation pipeline)
9
+
10
+ Exposes exactly three public methods:
11
+ remember(data, hint, skill, metadata) → None
12
+ recall(query, skill, top_k, hint) → list[SearchResult]
13
+ consolidate(num_records, method) → dict (summary)
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import time
18
+ from typing import Any
19
+
20
+ from etchmem.config import Config
21
+ from etchmem.models import Record, SearchResult
22
+ from etchmem.stores.chroma_store import build_stores
23
+ from etchmem.util.hashing import content_hash
24
+
25
+
26
+ class Engine:
27
+ """
28
+ The skillmem engine.
29
+
30
+ Usage::
31
+
32
+ from etchmem import Engine
33
+
34
+ engine = Engine() # uses .skillmem/ in cwd
35
+ engine.remember("Python 3.14 ships with a new JIT by default")
36
+ results = engine.recall("what changed in Python 3.14?")
37
+ summary = engine.consolidate()
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ config: Config | None = None,
43
+ connector=None, # LLMConnector | None; lazy-built if None
44
+ ) -> None:
45
+ from etchmem.config import DEFAULT_CONFIG
46
+ self._cfg = config or DEFAULT_CONFIG
47
+
48
+ # Build stores
49
+ self._relational, self._buffer, self._injected = build_stores(
50
+ data_dir=self._cfg.data_dir,
51
+ collection_relational=self._cfg.collection_relational,
52
+ collection_buffer=self._cfg.collection_buffer,
53
+ collection_injected=self._cfg.collection_injected,
54
+ ttl_seconds=self._cfg.relational_ttl_seconds,
55
+ embedding_function=self._cfg.embedding_function,
56
+ )
57
+
58
+ # Connector — built lazily on first consolidate() if not provided
59
+ self._connector = connector
60
+ self._connector_built = connector is not None
61
+
62
+ # Wires
63
+ from etchmem.recall.recaller import Recaller
64
+ self._recaller = Recaller(
65
+ relational_store=self._relational,
66
+ buffer_store=self._buffer,
67
+ injected_store=self._injected,
68
+ config=self._cfg,
69
+ )
70
+
71
+ # ── Public API ────────────────────────────────────────────────────────
72
+
73
+ def remember(
74
+ self,
75
+ data: str,
76
+ hint: float | None = None,
77
+ skill: str | None = None,
78
+ metadata: dict[str, Any] | None = None,
79
+ ) -> None:
80
+ """
81
+ Deposit a raw record into the relational collection.
82
+
83
+ No LLM call, no clustering. Cheap — embedding computed by Chroma
84
+ on insert.
85
+
86
+ Args:
87
+ data: The text content to remember.
88
+ hint: Optional importance prior (float 0–1). Seed, not truth.
89
+ skill: Optional skill scope name (e.g. "summarizer").
90
+ metadata: Arbitrary dict (source URL, tags, etc.).
91
+ """
92
+ record = Record(
93
+ id=content_hash(data),
94
+ content=data,
95
+ skill=skill,
96
+ hint=hint,
97
+ metadata=metadata or {},
98
+ created_at=time.time(),
99
+ )
100
+ self._relational.add(record)
101
+
102
+ def recall(
103
+ self,
104
+ query: str,
105
+ skill: str | None = None,
106
+ top_k: int | None = None,
107
+ hint: float | None = None,
108
+ ) -> list[SearchResult]:
109
+ """
110
+ Retrieve knowledge AND emit a recall-event for future reconsolidation.
111
+
112
+ Two things happen:
113
+ 1. Returns merged results from injected (primary) + relational (fresh).
114
+ 2. Writes a RecallEvent into the buffer so the worker can rewrite
115
+ used knowledge against fresh context at the next consolidate().
116
+
117
+ Args:
118
+ query: Natural language query.
119
+ skill: Optional skill scope filter.
120
+ top_k: Max results. Default from config.
121
+ hint: Importance prior seeded into the recall-event.
122
+
123
+ Returns:
124
+ List of SearchResult, highest score first.
125
+ """
126
+ return self._recaller.recall(
127
+ query=query,
128
+ skill=skill,
129
+ top_k=top_k,
130
+ hint=hint,
131
+ )
132
+
133
+ def consolidate(
134
+ self,
135
+ num_records: int | str | None = None,
136
+ method: str | None = None,
137
+ ) -> dict[str, Any]:
138
+ """
139
+ Run the consolidation worker.
140
+
141
+ Processes the buffer: forms new knowledge, reconsolidates recalled
142
+ knowledge, hard-deletes superseded articles. Explicitly called in v0
143
+ (predictable, demoable, debuggable).
144
+
145
+ Args:
146
+ num_records: How many relational deposits to pull ("all" or int N).
147
+ method: Ordering: "LIFO" (newest first, default) or "FIFO".
148
+
149
+ Returns:
150
+ Summary dict with counts of: formed, reconsolidated, dropped, kept,
151
+ flushed, superseded.
152
+ """
153
+ self._ensure_connector()
154
+
155
+ from etchmem.consolidate.worker import Worker
156
+ worker = Worker(
157
+ relational_store=self._relational,
158
+ buffer_store=self._buffer,
159
+ injected_store=self._injected,
160
+ connector=self._connector,
161
+ config=self._cfg,
162
+ )
163
+
164
+ num_records = num_records if num_records is not None else self._cfg.consolidate_default_num_records
165
+ method = method or self._cfg.consolidate_default_method
166
+
167
+ return worker.run(num_records=num_records, method=method)
168
+
169
+ # ── Internals ─────────────────────────────────────────────────────────
170
+
171
+ def _ensure_connector(self) -> None:
172
+ """Lazy-build the LLM connector on first consolidate() call."""
173
+ if self._connector_built:
174
+ return
175
+ from etchmem.connector.remote import build_connector
176
+ self._connector = build_connector(self._cfg)
177
+ self._connector_built = True
178
+
179
+ # ── Convenience properties ────────────────────────────────────────────
180
+
181
+ @property
182
+ def config(self) -> Config:
183
+ return self._cfg
184
+
185
+ def stats(self) -> dict[str, int]:
186
+ """Return collection sizes for monitoring."""
187
+ return {
188
+ "relational": self._relational.count(),
189
+ "buffer": self._buffer.count(),
190
+ "injected": self._injected.count(),
191
+ }
etchmem/models.py ADDED
@@ -0,0 +1,147 @@
1
+ """
2
+ skillmem data models.
3
+
4
+ All structs are plain dataclasses — no ORM, no Pydantic dependency.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import time
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+
13
+ # ── Raw deposit written by remember() ────────────────────────────────────────
14
+
15
+ @dataclass
16
+ class Record:
17
+ """
18
+ A raw signal record deposited via remember().
19
+ Stored in the *relational* collection.
20
+ Immutable after creation — relational is append-only.
21
+ """
22
+ id: str # content hash — computed before insert
23
+ content: str
24
+ skill: str | None = None # optional skill scope
25
+ hint: float | None = None # agent-supplied importance prior 0–1
26
+ metadata: dict[str, Any] = field(default_factory=dict)
27
+ created_at: float = field(default_factory=time.time)
28
+ expires_at: float = 0.0 # unix timestamp; set by store on insert
29
+
30
+
31
+ # ── Buffer documents ──────────────────────────────────────────────────────────
32
+
33
+ @dataclass
34
+ class Deposit:
35
+ """
36
+ A relational record copied into the *buffer* collection at intake.
37
+ Carries the original content hash for idempotency.
38
+ """
39
+ id: str # same content hash as the source Record
40
+ content: str
41
+ skill: str | None = None
42
+ hint: float | None = None
43
+ metadata: dict[str, Any] = field(default_factory=dict)
44
+ created_at: float = field(default_factory=time.time)
45
+ doc_type: str = "deposit" # discriminator stored as metadata
46
+
47
+
48
+ @dataclass
49
+ class RecallEvent:
50
+ """
51
+ Emitted by recall() into the *buffer* collection.
52
+ Carries the query (pointer into injected) and a snapshot of relational
53
+ hits (perishable, so snapshotted here).
54
+ """
55
+ id: str # uuid / hash assigned at emit time
56
+ query: str # the original recall query
57
+ relational_snapshot: str # concatenated text of relational hits
58
+ skill: str | None = None
59
+ hint: float | None = None
60
+ metadata: dict[str, Any] = field(default_factory=dict)
61
+ created_at: float = field(default_factory=time.time)
62
+ doc_type: str = "recall_event" # discriminator stored as metadata
63
+
64
+
65
+ # ── Clustering output ─────────────────────────────────────────────────────────
66
+
67
+ @dataclass
68
+ class Cluster:
69
+ """
70
+ A group of buffer documents (Deposits + RecallEvents) that cluster
71
+ around a shared topic. Output of Stage B clustering.
72
+ """
73
+ cluster_id: str
74
+ documents: list[Deposit | RecallEvent]
75
+ centroid_text: str | None = None # representative text for the cluster
76
+
77
+ @property
78
+ def has_recall_events(self) -> bool:
79
+ return any(isinstance(d, RecallEvent) for d in self.documents)
80
+
81
+ @property
82
+ def deposits(self) -> list[Deposit]:
83
+ return [d for d in self.documents if isinstance(d, Deposit)]
84
+
85
+ @property
86
+ def recall_events(self) -> list[RecallEvent]:
87
+ return [d for d in self.documents if isinstance(d, RecallEvent)]
88
+
89
+ @property
90
+ def queries(self) -> list[str]:
91
+ return [re.query for re in self.recall_events]
92
+
93
+ @property
94
+ def skill(self) -> str | None:
95
+ """Dominant skill across all docs in the cluster (most common)."""
96
+ skills = [d.skill for d in self.documents if d.skill]
97
+ if not skills:
98
+ return None
99
+ return max(set(skills), key=skills.count)
100
+
101
+
102
+ # ── Search / recall results ───────────────────────────────────────────────────
103
+
104
+ @dataclass
105
+ class SearchResult:
106
+ """
107
+ A single result returned by recall().
108
+ Carries the injected article content blended with freshness signal.
109
+ """
110
+ id: str
111
+ content: str
112
+ score: float # asymmetric blended score
113
+ source: str # "injected" | "relational" | "blended"
114
+ skill: str | None = None
115
+ metadata: dict[str, Any] = field(default_factory=dict)
116
+
117
+
118
+ # ── Injected article ──────────────────────────────────────────────────────────
119
+
120
+ @dataclass
121
+ class InjectedArticle:
122
+ """
123
+ A synthesized knowledge article stored in the *injected* collection.
124
+ Identity is keyed on the frozenset of source-doc hashes (content-addressed),
125
+ NOT on synthesized text (LLM is non-deterministic).
126
+ """
127
+ id: str # hash of sorted source-doc-hash set
128
+ content: str
129
+ source_hashes: frozenset[str] # set of Deposit.id values used in synthesis
130
+ skill: str | None = None
131
+ tags: dict[str, str] = field(default_factory=dict) # derived by synthesis prompt
132
+ metadata: dict[str, Any] = field(default_factory=dict)
133
+ created_at: float = field(default_factory=time.time)
134
+
135
+
136
+ # ── Affectability ─────────────────────────────────────────────────────────────
137
+
138
+ @dataclass
139
+ class Affectability:
140
+ """
141
+ Per-record affectability score.
142
+ Seeded from the agent hint (or default 0.5), not yet accumulated.
143
+ Accumulation logic lives in util/affectability.py.
144
+ """
145
+ record_id: str
146
+ score: float # 0.0–1.0
147
+ hint_seeded: bool = False # True if score originated from an agent hint
File without changes
@@ -0,0 +1,154 @@
1
+ """
2
+ Recaller — the recall() implementation.
3
+
4
+ Two things happen on every recall() call:
5
+ 1. Returns results (injected primary + relational freshness enrichment).
6
+ 2. Emits a recall-event into the buffer (cheap append, no LLM).
7
+
8
+ Design rules (from spec):
9
+ - Injected is ALWAYS searched directly with the query (never gated).
10
+ - Relational enriches; it never gates injected results.
11
+ - Asymmetric scoring: injected = high-trust base; relational = freshness-
12
+ boosted, confidence-discounted.
13
+ - Degrades gracefully to injected-only when relational is empty.
14
+ - The recall-event stores the QUERY (pointer), not an injected doc ref.
15
+ - The recall-event SNAPSHOTS relational hits (TTL-perishable content).
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import time
20
+ import uuid
21
+ from typing import Any
22
+
23
+ from etchmem.config import Config
24
+ from etchmem.models import RecallEvent, SearchResult
25
+ from etchmem.stores.chroma_store import ChromaStore
26
+
27
+
28
+ class Recaller:
29
+ """
30
+ Encapsulates all recall() logic.
31
+ Injected by Engine; tests can instantiate directly with mock stores.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ relational_store: ChromaStore,
37
+ buffer_store: ChromaStore,
38
+ injected_store: ChromaStore,
39
+ config: Config,
40
+ ) -> None:
41
+ self._relational = relational_store
42
+ self._buffer = buffer_store
43
+ self._injected = injected_store
44
+ self._cfg = config
45
+
46
+ def recall(
47
+ self,
48
+ query: str,
49
+ skill: str | None = None,
50
+ top_k: int | None = None,
51
+ hint: float | None = None,
52
+ ) -> list[SearchResult]:
53
+ """
54
+ Retrieve blended results and emit a recall-event into the buffer.
55
+
56
+ Args:
57
+ query: Natural language query.
58
+ skill: Optional skill scope filter.
59
+ top_k: Max results to return. Defaults to config.recall_top_k_injected.
60
+ hint: Optional importance prior seeded into the recall-event.
61
+
62
+ Returns:
63
+ List of SearchResult, highest score first.
64
+ """
65
+ top_k = top_k or self._cfg.recall_top_k_injected
66
+
67
+ # ── 1. Search injected (primary, never gated) ─────────────────────
68
+ injected_filter = {"skill": skill} if skill else None
69
+ injected_results = self._injected.query(
70
+ query_text=query,
71
+ top_k=top_k,
72
+ filters=injected_filter,
73
+ weight=self._cfg.recall_injected_weight,
74
+ source="injected",
75
+ )
76
+
77
+ # ── 2. Search relational (freshness signal) ───────────────────────
78
+ relational_results = self._relational.search(
79
+ query_text=query,
80
+ top_k=self._cfg.recall_top_k_relational,
81
+ skill=skill,
82
+ weight=self._cfg.recall_relational_weight,
83
+ source="relational",
84
+ )
85
+
86
+ # ── 3. Asymmetric merge ───────────────────────────────────────────
87
+ merged = self._merge(injected_results, relational_results, top_k)
88
+
89
+ # ── 4. Emit recall-event into buffer ──────────────────────────────
90
+ relational_snapshot = "\n\n".join(r.content for r in relational_results)
91
+ self._emit_recall_event(query, relational_snapshot, skill, hint)
92
+
93
+ return merged
94
+
95
+ # ── Helpers ───────────────────────────────────────────────────────────
96
+
97
+ def _merge(
98
+ self,
99
+ injected: list[SearchResult],
100
+ relational: list[SearchResult],
101
+ top_k: int,
102
+ ) -> list[SearchResult]:
103
+ """
104
+ Merge injected and relational results.
105
+
106
+ Injected results are the trusted base. Relational results are
107
+ freshness-boosted but confidence-discounted (already weighted by
108
+ recall_relational_weight in the store query).
109
+
110
+ De-duplicate by id; injected wins on collision.
111
+ Sort by score descending; return top_k.
112
+ """
113
+ seen: dict[str, SearchResult] = {}
114
+
115
+ for r in injected:
116
+ seen[r.id] = r
117
+
118
+ for r in relational:
119
+ if r.id not in seen:
120
+ seen[r.id] = r
121
+ else:
122
+ # Injected already there — boost injected score slightly
123
+ # by the relational overlap (corroborating signal)
124
+ existing = seen[r.id]
125
+ seen[r.id] = SearchResult(
126
+ id=existing.id,
127
+ content=existing.content,
128
+ score=existing.score + r.score * 0.1,
129
+ source="blended",
130
+ skill=existing.skill,
131
+ metadata=existing.metadata,
132
+ )
133
+
134
+ results = sorted(seen.values(), key=lambda r: r.score, reverse=True)
135
+ return results[:top_k]
136
+
137
+ def _emit_recall_event(
138
+ self,
139
+ query: str,
140
+ relational_snapshot: str,
141
+ skill: str | None,
142
+ hint: float | None,
143
+ ) -> None:
144
+ """Write a RecallEvent into the buffer collection."""
145
+ event_id = str(uuid.uuid4())
146
+ event = RecallEvent(
147
+ id=event_id,
148
+ query=query,
149
+ relational_snapshot=relational_snapshot,
150
+ skill=skill,
151
+ hint=hint,
152
+ created_at=time.time(),
153
+ )
154
+ self._buffer.upsert([event])
File without changes
etchmem/stores/base.py ADDED
@@ -0,0 +1,171 @@
1
+ """
2
+ Storage abstract base classes — the pluggability seam.
3
+
4
+ Everything in the engine talks to these ABCs, never to ChromaDB directly.
5
+ Two ports:
6
+ - RelationalStore: append-only + TTL expiry + ANN search
7
+ - InjectedStore: upsert + ANN query + clustering + hard delete
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from abc import ABC, abstractmethod
12
+ from typing import Any, Iterable
13
+
14
+ from etchmem.models import Cluster, Deposit, InjectedArticle, Record, RecallEvent, SearchResult
15
+
16
+
17
+ class RelationalStore(ABC):
18
+ """
19
+ Port for the *relational* collection.
20
+
21
+ Semantics:
22
+ - Pure append — records are NEVER mutated after insertion.
23
+ - TTL expiry is the ONLY deletion path (swept via metadata filter).
24
+ - No reinforcement counter. Usage signal is carried by recall-events
25
+ written to the buffer, not by mutating relational rows.
26
+ """
27
+
28
+ @abstractmethod
29
+ def add(self, record: Record) -> str:
30
+ """
31
+ Insert a raw Record into the relational collection.
32
+ Returns the record id (same as record.id, passed through for
33
+ convenience).
34
+ The store sets expires_at = now + TTL before writing.
35
+ """
36
+
37
+ @abstractmethod
38
+ def search(
39
+ self,
40
+ query_text: str,
41
+ top_k: int,
42
+ skill: str | None = None,
43
+ filters: dict[str, Any] | None = None,
44
+ ) -> list[SearchResult]:
45
+ """
46
+ ANN search of the relational collection.
47
+ Optionally filter by skill (exact match on stored metadata).
48
+ Returns up to top_k SearchResult objects.
49
+ """
50
+
51
+ @abstractmethod
52
+ def expire(self) -> int:
53
+ """
54
+ Delete all records whose expires_at < now (TTL sweep).
55
+ Returns the number of records deleted.
56
+ Implemented as: collection.delete(where={"expires_at": {"$lt": now}})
57
+ """
58
+
59
+ @abstractmethod
60
+ def iter_for_consolidation(
61
+ self,
62
+ num_records: int | str,
63
+ method: str,
64
+ ) -> Iterable[Record]:
65
+ """
66
+ Yield records for the consolidation worker to process.
67
+
68
+ num_records: "all" or an integer N.
69
+ method: "LIFO" (newest-first, by created_at) or "FIFO".
70
+
71
+ Records are NOT deleted here — relational is append-only.
72
+ Deletion happens only via TTL expiry.
73
+ """
74
+
75
+ @abstractmethod
76
+ def count(self) -> int:
77
+ """Return the number of live (non-expired) records."""
78
+
79
+ @abstractmethod
80
+ def get_by_id(self, record_id: str) -> Record | None:
81
+ """Fetch a single record by its id, or None if not found."""
82
+
83
+
84
+ class InjectedStore(ABC):
85
+ """
86
+ Port for both the *buffer* and *injected* collections.
87
+
88
+ Buffer semantics:
89
+ - Working set for the consolidation worker.
90
+ - Both Deposit and RecallEvent documents are written here.
91
+ - Flushed (hard-deleted) at the end of each successful consolidation run.
92
+
93
+ Injected semantics:
94
+ - Current-only knowledge store. No versions, no tombstones.
95
+ - Superseded articles are hard-deleted on write of the new article.
96
+ - Identity = hash of the source-doc-hash set (content-addressed).
97
+ """
98
+
99
+ @abstractmethod
100
+ def upsert(self, items: list[InjectedArticle | Deposit | RecallEvent]) -> None:
101
+ """
102
+ Write or overwrite items. Uses item.id as the Chroma document id.
103
+ doc_type metadata discriminator is set automatically.
104
+ """
105
+
106
+ @abstractmethod
107
+ def query(
108
+ self,
109
+ query_text: str,
110
+ top_k: int,
111
+ filters: dict[str, Any] | None = None,
112
+ ) -> list[SearchResult]:
113
+ """
114
+ ANN search this collection.
115
+ Returns up to top_k SearchResult objects.
116
+ """
117
+
118
+ @abstractmethod
119
+ def delete(self, ids: list[str]) -> None:
120
+ """
121
+ Hard-delete documents by id.
122
+ Deleting a non-existent id is a no-op (idempotent).
123
+ Used for:
124
+ - Buffer flush at end of run.
125
+ - Hard-delete of superseded injected articles.
126
+ """
127
+
128
+ @abstractmethod
129
+ def exists(self, content_hash: str) -> bool:
130
+ """
131
+ Return True if a document with this id already exists.
132
+ Used as an idempotency guard before writing injected articles.
133
+ """
134
+
135
+ @abstractmethod
136
+ def cluster(
137
+ self,
138
+ documents: list[Deposit | RecallEvent],
139
+ branch_threshold: float,
140
+ ) -> list[Cluster]:
141
+ """
142
+ Group documents into Cluster objects using their embeddings.
143
+ Documents within branch_threshold cosine distance of each other
144
+ are placed in the same cluster.
145
+ Orphaned documents (no cluster partner) are returned as
146
+ singleton Clusters.
147
+ """
148
+
149
+ @abstractmethod
150
+ def get_all(
151
+ self,
152
+ filters: dict[str, Any] | None = None,
153
+ ) -> list[Deposit | RecallEvent | InjectedArticle]:
154
+ """Return all documents, optionally filtered by metadata."""
155
+
156
+ @abstractmethod
157
+ def count(self) -> int:
158
+ """Return the total document count."""
159
+
160
+ @abstractmethod
161
+ def get_neighbors(
162
+ self,
163
+ query_text: str,
164
+ top_k: int,
165
+ distance_threshold: float,
166
+ ) -> list[SearchResult]:
167
+ """
168
+ ANN search with a distance filter — only return results whose
169
+ cosine distance is below distance_threshold.
170
+ Used in the reconciliation pass to find near-duplicate injected articles.
171
+ """