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 +77 -0
- typedmem/cli.py +316 -0
- typedmem/embeddings.py +86 -0
- typedmem/evolvers/__init__.py +23 -0
- typedmem/evolvers/base.py +84 -0
- typedmem/evolvers/contradictions.py +82 -0
- typedmem/evolvers/drift.py +97 -0
- typedmem/evolvers/goals.py +127 -0
- typedmem/evolvers/summary.py +179 -0
- typedmem/extractor.py +370 -0
- typedmem/llm/__init__.py +9 -0
- typedmem/llm/anthropic.py +46 -0
- typedmem/llm/base.py +11 -0
- typedmem/llm/fake.py +40 -0
- typedmem/llm/openai.py +41 -0
- typedmem/policy.py +160 -0
- typedmem/profiles/__init__.py +11 -0
- typedmem/profiles/base.py +208 -0
- typedmem/profiles/builtins.py +475 -0
- typedmem/profiles/loaders.py +23 -0
- typedmem/prompts.py +84 -0
- typedmem/retriever.py +187 -0
- typedmem/schema.py +127 -0
- typedmem/source.py +89 -0
- typedmem/stores/__init__.py +6 -0
- typedmem/stores/base.py +198 -0
- typedmem/stores/jsonl.py +92 -0
- typedmem/stores/memory.py +37 -0
- typedmem/stores/sqlite.py +239 -0
- typedmem-0.4.0.dist-info/METADATA +279 -0
- typedmem-0.4.0.dist-info/RECORD +35 -0
- typedmem-0.4.0.dist-info/WHEEL +5 -0
- typedmem-0.4.0.dist-info/entry_points.txt +2 -0
- typedmem-0.4.0.dist-info/licenses/LICENSE +21 -0
- typedmem-0.4.0.dist-info/top_level.txt +1 -0
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]
|