typedmem 0.4.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.
typedmem/__init__.py ADDED
@@ -0,0 +1,77 @@
1
+ """TypedMemory: structured, policy-aware memory for AI systems."""
2
+
3
+ from .embeddings import EmbeddingProvider, HashingEmbeddingProvider, cosine
4
+ from .evolvers import (
5
+ ContradictionSurfacer,
6
+ EvolutionRecord,
7
+ EvolutionResult,
8
+ Evolver,
9
+ GoalResolver,
10
+ PreferenceDriftDetector,
11
+ SummaryEvolver,
12
+ revert_goal_resolution,
13
+ )
14
+ from .extractor import ExtractionResult, Extractor, LLMExtractor, RuleBasedExtractor
15
+ from .llm import AnthropicClient, FakeClient, LLMClient, OpenAIClient
16
+ from .policy import (
17
+ DEFAULT_POLICIES,
18
+ ConflictAction,
19
+ ConflictPolicy,
20
+ PolicyEngine,
21
+ TypePolicy,
22
+ )
23
+ from .profiles import DomainProfile, TypeSpec
24
+ from .prompts import PROMPTS
25
+ from .retriever import RelevanceWeights, Retriever, ScoredMemory
26
+ from .schema import GoalStatus, Memory, MemoryType
27
+ from .source import Source
28
+ from .stores import (
29
+ InMemoryStore,
30
+ JSONLMemoryStore,
31
+ MemoryStore,
32
+ SQLiteMemoryStore,
33
+ )
34
+
35
+ __version__ = "0.4.0"
36
+
37
+ __all__ = [
38
+ "DEFAULT_POLICIES",
39
+ "AnthropicClient",
40
+ "ConflictAction",
41
+ "ConflictPolicy",
42
+ "ContradictionSurfacer",
43
+ "DomainProfile",
44
+ "EmbeddingProvider",
45
+ "EvolutionRecord",
46
+ "EvolutionResult",
47
+ "Evolver",
48
+ "ExtractionResult",
49
+ "Extractor",
50
+ "FakeClient",
51
+ "GoalResolver",
52
+ "GoalStatus",
53
+ "HashingEmbeddingProvider",
54
+ "InMemoryStore",
55
+ "JSONLMemoryStore",
56
+ "LLMClient",
57
+ "LLMExtractor",
58
+ "Memory",
59
+ "MemoryStore",
60
+ "MemoryType",
61
+ "OpenAIClient",
62
+ "PROMPTS",
63
+ "PolicyEngine",
64
+ "PreferenceDriftDetector",
65
+ "RelevanceWeights",
66
+ "Retriever",
67
+ "RuleBasedExtractor",
68
+ "ScoredMemory",
69
+ "SQLiteMemoryStore",
70
+ "Source",
71
+ "SummaryEvolver",
72
+ "TypePolicy",
73
+ "TypeSpec",
74
+ "__version__",
75
+ "cosine",
76
+ "revert_goal_resolution",
77
+ ]
typedmem/cli.py ADDED
@@ -0,0 +1,316 @@
1
+ """``typedmem`` CLI.
2
+
3
+ Default store: SQLite at $TYPEDMEM_DB or ``~/.typedmem/memories.db``.
4
+ Override per invocation with ``--store path.db`` or ``--store path.jsonl``."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import os
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from . import (
15
+ ContradictionSurfacer,
16
+ DomainProfile,
17
+ GoalResolver,
18
+ HashingEmbeddingProvider,
19
+ JSONLMemoryStore,
20
+ LLMExtractor, # noqa: F401 (kept for docs / future use)
21
+ Memory,
22
+ MemoryStore,
23
+ MemoryType,
24
+ PolicyEngine,
25
+ PreferenceDriftDetector,
26
+ Retriever,
27
+ RuleBasedExtractor,
28
+ SQLiteMemoryStore,
29
+ Source,
30
+ )
31
+ from .profiles import BUILTIN_PROFILES, from_json as _profile_from_json, from_yaml as _profile_from_yaml
32
+
33
+
34
+ def _default_store_path() -> Path:
35
+ env = os.environ.get("TYPEDMEM_DB")
36
+ if env:
37
+ return Path(env)
38
+ return Path.home() / ".typedmem" / "memories.db"
39
+
40
+
41
+ def _load_profile(name: str | None, path: str | None) -> DomainProfile | None:
42
+ if path:
43
+ p = Path(path)
44
+ if p.suffix in {".yaml", ".yml"}:
45
+ return _profile_from_yaml(p)
46
+ return _profile_from_json(p)
47
+ if name:
48
+ return DomainProfile.builtin(name)
49
+ return None
50
+
51
+
52
+ def _open_store(path: Path, workspace: str, profile: DomainProfile | None) -> MemoryStore:
53
+ policy = PolicyEngine.from_profile(profile) if profile else None
54
+ if path.suffix in {".jsonl", ".ndjson"}:
55
+ return JSONLMemoryStore(path, policy=policy, default_workspace=workspace, profile=profile)
56
+ return SQLiteMemoryStore(path, policy=policy, default_workspace=workspace, profile=profile)
57
+
58
+
59
+ def _fmt(m: Memory) -> str:
60
+ subj = f" [{m.subject}]" if m.subject else ""
61
+ tags = f" #{','.join(m.tags)}" if m.tags else ""
62
+ ws = "" if m.workspace == "default" else f" @{m.workspace}"
63
+ return f"{m.timestamp.date()} {m.type:<11}{subj}{ws} conf={m.confidence:.2f}{tags} {m.content}"
64
+
65
+
66
+ def _source_from_args(args: argparse.Namespace) -> Source | None:
67
+ if not getattr(args, "document_id", None):
68
+ return None
69
+ kwargs = {"document_id": args.document_id}
70
+ if getattr(args, "uri", None):
71
+ kwargs["uri"] = args.uri
72
+ if getattr(args, "authority", None) is not None:
73
+ kwargs["authority"] = args.authority
74
+ return Source(**kwargs)
75
+
76
+
77
+ def cmd_add(args: argparse.Namespace, store: MemoryStore) -> int:
78
+ source = _source_from_args(args)
79
+ if args.type:
80
+ m = Memory(
81
+ type=args.type, # any string; profile validates if bound
82
+ content=args.text,
83
+ subject=args.subject,
84
+ tags=args.tags or [],
85
+ confidence=args.confidence,
86
+ workspace=args.workspace,
87
+ sources=[source] if source else [],
88
+ )
89
+ store.add(m)
90
+ print(f"added 1 memory ({m.type}): {m.id}")
91
+ return 0
92
+ extractor = RuleBasedExtractor()
93
+ extracted = extractor.extract(
94
+ args.text, subject=args.subject, workspace=args.workspace, default_source=source,
95
+ )
96
+ if not extracted:
97
+ print("no memories extracted; pass --type to force a single typed memory", file=sys.stderr)
98
+ return 1
99
+ for m in extracted:
100
+ store.add(m)
101
+ print(f"added {len(extracted)} memorie(s) from extractor")
102
+ for m in extracted:
103
+ print(" " + _fmt(m))
104
+ return 0
105
+
106
+
107
+ def cmd_search(args: argparse.Namespace, store: MemoryStore) -> int:
108
+ embedder = None if args.no_embed else HashingEmbeddingProvider(dim=args.dim)
109
+ retriever = Retriever(store, embedder=embedder)
110
+ types = list(args.type) if args.type else None
111
+ tags = args.tag or None
112
+ hits = retriever.relevant(
113
+ args.query, limit=args.limit, types=types, tags=tags,
114
+ workspace=args.workspace, include_superseded=args.include_superseded,
115
+ )
116
+ if not hits:
117
+ print("no matches")
118
+ return 0
119
+ for h in hits:
120
+ print(f"{h.score:.3f} " + _fmt(h.memory))
121
+ return 0
122
+
123
+
124
+ def cmd_list(args: argparse.Namespace, store: MemoryStore) -> int:
125
+ if args.type:
126
+ items = store.by_type(
127
+ args.type, workspace=args.workspace,
128
+ include_superseded=args.include_superseded,
129
+ )
130
+ else:
131
+ items = store.all(
132
+ workspace=args.workspace, include_superseded=args.include_superseded,
133
+ )
134
+ items.sort(key=lambda m: m.timestamp, reverse=True)
135
+ if args.limit:
136
+ items = items[: args.limit]
137
+ if args.json:
138
+ print(json.dumps([m.to_dict() for m in items], indent=2))
139
+ return 0
140
+ if not items:
141
+ print("no memories")
142
+ return 0
143
+ for m in items:
144
+ print(_fmt(m))
145
+ return 0
146
+
147
+
148
+ def cmd_delete(args: argparse.Namespace, store: MemoryStore) -> int:
149
+ ok = store.delete(args.id)
150
+ print("deleted" if ok else "not found")
151
+ return 0 if ok else 1
152
+
153
+
154
+ def cmd_compact(args: argparse.Namespace, store: MemoryStore) -> int:
155
+ if isinstance(store, JSONLMemoryStore):
156
+ store.compact()
157
+ print("compacted")
158
+ return 0
159
+ print("compact is only supported for JSONL stores", file=sys.stderr)
160
+ return 1
161
+
162
+
163
+ def cmd_workspaces(args: argparse.Namespace, store: MemoryStore) -> int:
164
+ names = store.workspaces()
165
+ if not names:
166
+ print("(no workspaces yet)")
167
+ return 0
168
+ for name in names:
169
+ print(name)
170
+ return 0
171
+
172
+
173
+ def _format_record(r) -> str:
174
+ return f" [{r.action}] {r.reason} (in={r.input_ids}, out={r.output_ids})"
175
+
176
+
177
+ def cmd_evolve(args: argparse.Namespace, store: MemoryStore) -> int:
178
+ evolver_kind = args.evolver
179
+ if evolver_kind == "contradictions":
180
+ evolver = ContradictionSurfacer()
181
+ dry_run = False # read-only; flag is informational
182
+ elif evolver_kind == "drift":
183
+ evolver = PreferenceDriftDetector(
184
+ min_replaces=args.min_replaces, window_days=args.window_days,
185
+ )
186
+ # Drift annotation is reversible (just a metadata key) — default to commit.
187
+ dry_run = not args.apply
188
+ elif evolver_kind == "goals":
189
+ evolver = GoalResolver(
190
+ HashingEmbeddingProvider(dim=args.dim),
191
+ threshold=args.threshold,
192
+ )
193
+ # Destructive: default to dry-run, require --apply to commit.
194
+ dry_run = not args.apply
195
+ else:
196
+ print(f"unknown evolver: {evolver_kind}", file=sys.stderr)
197
+ return 1
198
+
199
+ result = evolver.evolve(store, workspace=args.workspace, dry_run=dry_run)
200
+ print(result.summary())
201
+ for r in result.records:
202
+ print(_format_record(r))
203
+ return 0
204
+
205
+
206
+ def cmd_history(args: argparse.Namespace, store: MemoryStore) -> int:
207
+ entries = store.evolution_history(args.id)
208
+ if not entries:
209
+ print("(no evolution history)")
210
+ return 0
211
+ for e in entries:
212
+ ts = e.get("timestamp", "?")
213
+ print(f"{ts} [{e.get('evolver','?')}] {e.get('action','?')}: {e.get('reason','')}")
214
+ return 0
215
+
216
+
217
+ def cmd_profiles(args: argparse.Namespace, store: MemoryStore) -> int:
218
+ for name, factory in sorted(BUILTIN_PROFILES.items()):
219
+ profile = factory()
220
+ types = sorted(profile.all_types())
221
+ marker = " (+core)" if profile.include_core_types else ""
222
+ print(f"{name}{marker}: {profile.description}")
223
+ print(f" types: {', '.join(types)}")
224
+ return 0
225
+
226
+
227
+ def build_parser() -> argparse.ArgumentParser:
228
+ p = argparse.ArgumentParser(prog="typedmem", description="Schema-aware memory for AI agents.")
229
+ p.add_argument("--store", type=Path, default=_default_store_path(),
230
+ help="path to .db (SQLite) or .jsonl (default: ~/.typedmem/memories.db)")
231
+ p.add_argument("--workspace", default="default",
232
+ help="memory namespace; isolates one agent/domain from another (default: 'default')")
233
+ p.add_argument("--profile", default=None, choices=sorted(BUILTIN_PROFILES.keys()),
234
+ help="built-in domain profile to bind (validates types and required fields)")
235
+ p.add_argument("--profile-file", default=None,
236
+ help="path to a custom profile in .json or .yaml")
237
+ sub = p.add_subparsers(dest="cmd", required=True)
238
+
239
+ sa = sub.add_parser("add", help="add memory (auto-extract by default)")
240
+ sa.add_argument("text")
241
+ sa.add_argument("--type", help="force a single memory of this type (profile-defined names accepted)")
242
+ sa.add_argument("--subject")
243
+ sa.add_argument("--tags", nargs="*")
244
+ sa.add_argument("--confidence", type=float, default=1.0)
245
+ sa.add_argument("--document-id", help="opaque id of the source document")
246
+ sa.add_argument("--uri", help="URL or path to the source document")
247
+ sa.add_argument("--authority", type=float, help="weight in conflict resolution (default 1.0)")
248
+ sa.set_defaults(func=cmd_add)
249
+
250
+ ss = sub.add_parser("search", help="semantic search across stored memories")
251
+ ss.add_argument("query")
252
+ ss.add_argument("--limit", type=int, default=10)
253
+ ss.add_argument("--type", action="append",
254
+ help="filter by type (repeatable; any profile-defined name)")
255
+ ss.add_argument("--tag", action="append")
256
+ ss.add_argument("--no-embed", action="store_true", help="use token overlap instead of embeddings")
257
+ ss.add_argument("--dim", type=int, default=256, help="hashing embedder dim")
258
+ ss.add_argument("--include-superseded", action="store_true",
259
+ help="include memories that have been superseded by a newer record")
260
+ ss.set_defaults(func=cmd_search)
261
+
262
+ sl = sub.add_parser("list", help="list memories")
263
+ sl.add_argument("--type", help="filter by type (any profile-defined name)")
264
+ sl.add_argument("--limit", type=int, default=0)
265
+ sl.add_argument("--json", action="store_true")
266
+ sl.add_argument("--include-superseded", action="store_true")
267
+ sl.set_defaults(func=cmd_list)
268
+
269
+ sd = sub.add_parser("delete", help="delete a memory by id")
270
+ sd.add_argument("id")
271
+ sd.set_defaults(func=cmd_delete)
272
+
273
+ sc = sub.add_parser("compact", help="compact a JSONL store")
274
+ sc.set_defaults(func=cmd_compact)
275
+
276
+ sw = sub.add_parser("workspaces", help="list workspaces present in this store")
277
+ sw.set_defaults(func=cmd_workspaces)
278
+
279
+ sp = sub.add_parser("profiles", help="list built-in domain profiles")
280
+ sp.set_defaults(func=cmd_profiles)
281
+
282
+ se = sub.add_parser("evolve", help="run an Evolver over the store")
283
+ se.add_argument("--evolver", required=True,
284
+ choices=["contradictions", "drift", "goals"],
285
+ help="which evolver to run (summarize requires Python API + LLM client)")
286
+ se.add_argument("--apply", action="store_true",
287
+ help="commit changes (drift/goals default to dry-run)")
288
+ se.add_argument("--threshold", type=float, default=0.85,
289
+ help="goal-resolution similarity threshold (goals only)")
290
+ se.add_argument("--min-replaces", type=int, default=3,
291
+ help="minimum REPLACE count in window to flag drift (drift only)")
292
+ se.add_argument("--window-days", type=float, default=30.0,
293
+ help="trailing window for drift detection (drift only)")
294
+ se.add_argument("--dim", type=int, default=256,
295
+ help="hashing embedder dim (goals only)")
296
+ se.set_defaults(func=cmd_evolve)
297
+
298
+ sh = sub.add_parser("history", help="show evolution_history for a memory")
299
+ sh.add_argument("id")
300
+ sh.set_defaults(func=cmd_history)
301
+
302
+ return p
303
+
304
+
305
+ def main(argv: list[str] | None = None) -> int:
306
+ args = build_parser().parse_args(argv)
307
+ profile = _load_profile(args.profile, args.profile_file)
308
+ store = _open_store(args.store, args.workspace, profile)
309
+ try:
310
+ return args.func(args, store)
311
+ finally:
312
+ store.close()
313
+
314
+
315
+ if __name__ == "__main__": # pragma: no cover
316
+ sys.exit(main())
typedmem/embeddings.py ADDED
@@ -0,0 +1,86 @@
1
+ """Embedding providers.
2
+
3
+ v0.2 ships a zero-dep ``HashingEmbeddingProvider``. The protocol is small on
4
+ purpose so an OpenAI / sentence-transformers provider can drop in for v0.3.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import math
11
+ import re
12
+ from typing import Iterable, Protocol
13
+
14
+
15
+ class EmbeddingProvider(Protocol):
16
+ @property
17
+ def id(self) -> str: ...
18
+ @property
19
+ def dim(self) -> int: ...
20
+ def embed(self, texts: Iterable[str]) -> list[list[float]]: ...
21
+
22
+
23
+ _TOKEN = re.compile(r"[a-z0-9]+")
24
+
25
+
26
+ def _ngrams(tokens: list[str], n: int) -> list[str]:
27
+ if len(tokens) < n:
28
+ return tokens[:]
29
+ return [" ".join(tokens[i : i + n]) for i in range(len(tokens) - n + 1)]
30
+
31
+
32
+ class HashingEmbeddingProvider:
33
+ """Feature-hashing embedder over word unigrams + bigrams.
34
+
35
+ Trick is the standard `hash(token) % dim` projection; the sign is taken
36
+ from a second hash to keep the expectation unbiased. Vectors are L2-
37
+ normalized so cosine similarity reduces to a dot product."""
38
+
39
+ def __init__(self, dim: int = 256, ngram: int = 2) -> None:
40
+ if dim <= 0:
41
+ raise ValueError("dim must be positive")
42
+ if ngram < 1:
43
+ raise ValueError("ngram must be >= 1")
44
+ self._dim = dim
45
+ self._ngram = ngram
46
+
47
+ @property
48
+ def id(self) -> str:
49
+ return f"hashing:dim={self._dim};ngram={self._ngram}"
50
+
51
+ @property
52
+ def dim(self) -> int:
53
+ return self._dim
54
+
55
+ def embed(self, texts: Iterable[str]) -> list[list[float]]:
56
+ return [self._embed_one(t) for t in texts]
57
+
58
+ def _embed_one(self, text: str) -> list[float]:
59
+ tokens = _TOKEN.findall(text.lower())
60
+ if not tokens:
61
+ return [0.0] * self._dim
62
+ features: list[str] = []
63
+ for n in range(1, self._ngram + 1):
64
+ features.extend(_ngrams(tokens, n))
65
+ v = [0.0] * self._dim
66
+ for f in features:
67
+ h = hashlib.blake2b(f.encode("utf-8"), digest_size=8).digest()
68
+ idx = int.from_bytes(h[:4], "little") % self._dim
69
+ sign = 1.0 if (h[4] & 1) else -1.0
70
+ v[idx] += sign
71
+ norm = math.sqrt(sum(x * x for x in v))
72
+ if norm == 0:
73
+ return v
74
+ return [x / norm for x in v]
75
+
76
+
77
+ def cosine(a: list[float], b: list[float]) -> float:
78
+ if not a or not b or len(a) != len(b):
79
+ return 0.0
80
+ # Both are unit-norm from HashingEmbeddingProvider, but don't assume it.
81
+ dot = sum(x * y for x, y in zip(a, b))
82
+ na = math.sqrt(sum(x * x for x in a))
83
+ nb = math.sqrt(sum(x * x for x in b))
84
+ if na == 0 or nb == 0:
85
+ return 0.0
86
+ return dot / (na * nb)
@@ -0,0 +1,23 @@
1
+ """Memory Evolution layer (v0.4c).
2
+
3
+ Evolvers operate on existing memories — symmetric to Extractors which operate
4
+ on text. Every evolver returns an ``EvolutionResult`` whose ``records`` are
5
+ the audit trail of what was (or would be) done."""
6
+
7
+ from .base import EvolutionRecord, EvolutionResult, Evolver, annotate_history
8
+ from .contradictions import ContradictionSurfacer
9
+ from .drift import PreferenceDriftDetector
10
+ from .goals import GoalResolver, revert as revert_goal_resolution
11
+ from .summary import SummaryEvolver
12
+
13
+ __all__ = [
14
+ "ContradictionSurfacer",
15
+ "EvolutionRecord",
16
+ "EvolutionResult",
17
+ "Evolver",
18
+ "GoalResolver",
19
+ "PreferenceDriftDetector",
20
+ "SummaryEvolver",
21
+ "annotate_history",
22
+ "revert_goal_resolution",
23
+ ]
@@ -0,0 +1,84 @@
1
+ """Evolver protocol and audit primitives.
2
+
3
+ An ``Evolver`` reads (and optionally mutates) a store, producing a list of
4
+ ``EvolutionRecord``s that explain every action taken. This audit trail is the
5
+ load-bearing piece of v0.4c — without it, an Evolver is a black box that
6
+ silently rewrites state, which destroys trust. Every mutating evolver also
7
+ appends the same record to ``memory.metadata["evolution_history"]`` so the
8
+ provenance travels with the data.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import asdict, dataclass, field
14
+ from datetime import datetime, timezone
15
+ from typing import Any, Iterator, Protocol, TYPE_CHECKING
16
+
17
+ if TYPE_CHECKING:
18
+ from ..stores.base import MemoryStore
19
+
20
+
21
+ def _now() -> datetime:
22
+ return datetime.now(timezone.utc)
23
+
24
+
25
+ # Cap on metadata["evolution_history"] length. Oldest entries are dropped past
26
+ # this. Users who need full history will use v0.5's dedicated log table.
27
+ _HISTORY_CAP = 50
28
+
29
+
30
+ @dataclass
31
+ class EvolutionRecord:
32
+ evolver: str
33
+ action: str # flag|annotate|create|resolve|supersede
34
+ input_ids: list[str] # memories that triggered the action
35
+ output_ids: list[str] = field(default_factory=list)
36
+ reason: str = ""
37
+ timestamp: datetime = field(default_factory=_now)
38
+
39
+ def to_dict(self) -> dict[str, Any]:
40
+ d = asdict(self)
41
+ d["timestamp"] = self.timestamp.isoformat()
42
+ return d
43
+
44
+
45
+ @dataclass
46
+ class EvolutionResult:
47
+ evolver: str
48
+ records: list[EvolutionRecord]
49
+ dry_run: bool
50
+
51
+ def __len__(self) -> int:
52
+ return len(self.records)
53
+
54
+ def __iter__(self) -> Iterator[EvolutionRecord]:
55
+ return iter(self.records)
56
+
57
+ def summary(self) -> str:
58
+ prefix = "[dry-run] " if self.dry_run else ""
59
+ if not self.records:
60
+ return f"{prefix}{self.evolver}: 0 actions"
61
+ return f"{prefix}{self.evolver}: " + ", ".join(
62
+ f"{r.action}({len(r.input_ids)}→{len(r.output_ids)})"
63
+ for r in self.records
64
+ )
65
+
66
+
67
+ class Evolver(Protocol):
68
+ name: str
69
+ def evolve(
70
+ self,
71
+ store: "MemoryStore",
72
+ *,
73
+ workspace: str | None = None,
74
+ dry_run: bool = False,
75
+ ) -> EvolutionResult: ...
76
+
77
+
78
+ def annotate_history(memory, record: EvolutionRecord) -> None:
79
+ """Append an EvolutionRecord to a memory's metadata audit trail.
80
+ Capped at _HISTORY_CAP entries — oldest are dropped past that."""
81
+ history = memory.metadata.setdefault("evolution_history", [])
82
+ history.append(record.to_dict())
83
+ if len(history) > _HISTORY_CAP:
84
+ del history[:-_HISTORY_CAP]
@@ -0,0 +1,82 @@
1
+ """ContradictionSurfacer: walk the FLAG-generated contradiction graph.
2
+
3
+ Pure read — never mutates the store. Returns one EvolutionRecord per
4
+ connected component of two or more memories. ``dry_run`` is irrelevant
5
+ (reported as True) because this evolver has no side effects.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ from .base import EvolutionRecord, EvolutionResult
13
+
14
+ if TYPE_CHECKING:
15
+ from ..stores.base import MemoryStore
16
+
17
+
18
+ def _collect_edges(memories) -> dict[str, set[str]]:
19
+ """Return id → set of ids it conflicts with, restricted to memories
20
+ actually present in the iterable (defensive against dangling references)."""
21
+ by_id = {m.id: m for m in memories}
22
+ edges: dict[str, set[str]] = {mid: set() for mid in by_id}
23
+ for m in by_id.values():
24
+ for other_id in m.metadata.get("conflicts_with", []):
25
+ if other_id in by_id:
26
+ edges[m.id].add(other_id)
27
+ edges[other_id].add(m.id)
28
+ return edges
29
+
30
+
31
+ def _connected_components(edges: dict[str, set[str]]) -> list[list[str]]:
32
+ seen: set[str] = set()
33
+ components: list[list[str]] = []
34
+ for node in edges:
35
+ if node in seen:
36
+ continue
37
+ # BFS from node
38
+ stack = [node]
39
+ component: list[str] = []
40
+ while stack:
41
+ cur = stack.pop()
42
+ if cur in seen:
43
+ continue
44
+ seen.add(cur)
45
+ component.append(cur)
46
+ stack.extend(edges[cur] - seen)
47
+ if len(component) > 1:
48
+ components.append(sorted(component))
49
+ return components
50
+
51
+
52
+ class ContradictionSurfacer:
53
+ name = "contradiction_surfacer"
54
+
55
+ def evolve(
56
+ self,
57
+ store: "MemoryStore",
58
+ *,
59
+ workspace: str | None = None,
60
+ dry_run: bool = False,
61
+ ) -> EvolutionResult:
62
+ ws = workspace if workspace is not None else store.default_workspace
63
+ memories = [m for m in store if m.workspace == ws]
64
+ edges = _collect_edges(memories)
65
+ components = _connected_components(edges)
66
+ records = [
67
+ EvolutionRecord(
68
+ evolver=self.name,
69
+ action="flag",
70
+ input_ids=list(component),
71
+ reason=f"{len(component)} memories cross-link via conflicts_with",
72
+ )
73
+ for component in components
74
+ ]
75
+ # Always dry_run=True semantically — surfacer is read-only.
76
+ return EvolutionResult(self.name, records, dry_run=True)
77
+
78
+ def clusters(self, store: "MemoryStore", *, workspace: str | None = None):
79
+ """Convenience: return clusters as lists of Memory objects."""
80
+ result = self.evolve(store, workspace=workspace)
81
+ by_id = {m.id: m for m in store}
82
+ return [[by_id[i] for i in r.input_ids if i in by_id] for r in result.records]