plain-text-memory-mcp 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.
- plain_text_memory_mcp/__init__.py +1 -0
- plain_text_memory_mcp/agent.py +24 -0
- plain_text_memory_mcp/graph.py +320 -0
- plain_text_memory_mcp/prompts/cleanup.md +37 -0
- plain_text_memory_mcp/prompts/export.md +20 -0
- plain_text_memory_mcp/records.py +233 -0
- plain_text_memory_mcp/server.py +753 -0
- plain_text_memory_mcp/storage.py +312 -0
- plain_text_memory_mcp/taxonomy.json +503 -0
- plain_text_memory_mcp-0.1.0.dist-info/METADATA +215 -0
- plain_text_memory_mcp-0.1.0.dist-info/RECORD +15 -0
- plain_text_memory_mcp-0.1.0.dist-info/WHEEL +5 -0
- plain_text_memory_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- plain_text_memory_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
- plain_text_memory_mcp-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Knowledge-graph memory MCP server with date and agent tags."""
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Name the agent that is calling the memory server."""
|
|
2
|
+
|
|
3
|
+
UNKNOWN_AGENT = "unknown"
|
|
4
|
+
|
|
5
|
+
KNOWN_AGENTS = (("claude", "claude-code"), ("codex", "codex"))
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def calc_agent_name(client_name: str | None, override: str | None) -> str:
|
|
9
|
+
"""Return the agent name recorded in memory tags.
|
|
10
|
+
|
|
11
|
+
- A non-blank override, from `MEMORY_AGENT`, always wins.
|
|
12
|
+
- Client names containing "claude" or "codex" map to `claude-code` and
|
|
13
|
+
`codex`; any other name is recorded as sent.
|
|
14
|
+
- A missing or blank client name records `unknown`.
|
|
15
|
+
"""
|
|
16
|
+
if override and override.strip():
|
|
17
|
+
return override.strip()
|
|
18
|
+
if not client_name or not client_name.strip():
|
|
19
|
+
return UNKNOWN_AGENT
|
|
20
|
+
lowered = client_name.lower()
|
|
21
|
+
for marker, agent_name in KNOWN_AGENTS:
|
|
22
|
+
if marker in lowered:
|
|
23
|
+
return agent_name
|
|
24
|
+
return client_name.strip()
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Model the tagged knowledge graph and its pure operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TypedDict
|
|
10
|
+
|
|
11
|
+
RelationKey = tuple[str, str, str]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Tag:
|
|
16
|
+
"""Record when an entry was added and which agent added it."""
|
|
17
|
+
|
|
18
|
+
added_at: str | None
|
|
19
|
+
agent: str | None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
UNTAGGED = Tag(None, None)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Observation:
|
|
27
|
+
"""Hold one tagged fact about an entity."""
|
|
28
|
+
|
|
29
|
+
text: str
|
|
30
|
+
tag: Tag
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Entity:
|
|
35
|
+
"""Hold a named node and its observations."""
|
|
36
|
+
|
|
37
|
+
name: str
|
|
38
|
+
entity_type: str
|
|
39
|
+
observations: list[Observation]
|
|
40
|
+
tag: Tag
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Relation:
|
|
45
|
+
"""Hold a directed, typed edge between two entity names."""
|
|
46
|
+
|
|
47
|
+
source: str
|
|
48
|
+
target: str
|
|
49
|
+
relation_type: str
|
|
50
|
+
tag: Tag
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Header:
|
|
55
|
+
"""Record which repo a memory file belongs to."""
|
|
56
|
+
|
|
57
|
+
repo_name: str
|
|
58
|
+
repo_root: str
|
|
59
|
+
tag: Tag
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class Graph:
|
|
64
|
+
"""Hold every entity and relation in one memory file.
|
|
65
|
+
|
|
66
|
+
- `header` is None until the first write stamps the owning repo.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
entities: list[Entity] = field(default_factory=list)
|
|
70
|
+
relations: list[Relation] = field(default_factory=list)
|
|
71
|
+
header: Header | None = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class UsageCounts(TypedDict):
|
|
75
|
+
"""Count the entries in one memory file."""
|
|
76
|
+
|
|
77
|
+
entities: int
|
|
78
|
+
observations: int
|
|
79
|
+
relations: int
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Usage(TypedDict):
|
|
83
|
+
"""Summarize what one memory file contains."""
|
|
84
|
+
|
|
85
|
+
counts: UsageCounts
|
|
86
|
+
entity_types: dict[str, int]
|
|
87
|
+
relation_types: dict[str, int]
|
|
88
|
+
agents: dict[str, int]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class EntityNotFoundError(LookupError):
|
|
92
|
+
"""Raised when an operation names an entity that does not exist."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def init_tag(agent: str, now: datetime | None = None) -> Tag:
|
|
96
|
+
"""Return a tag for the given agent stamped with local time.
|
|
97
|
+
|
|
98
|
+
- `now` exists for tests; production callers omit it.
|
|
99
|
+
"""
|
|
100
|
+
moment = now or datetime.now().astimezone()
|
|
101
|
+
return Tag(moment.isoformat(timespec="seconds"), agent)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def init_header(repo_root: Path, tag: Tag) -> Header:
|
|
105
|
+
"""Return a header naming `repo_root` as the memory file's owner."""
|
|
106
|
+
return Header(repo_root.name, repo_root.as_posix(), tag)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def build_entity(
|
|
110
|
+
name: str, entity_type: str, texts: list[str], tag: Tag
|
|
111
|
+
) -> Entity:
|
|
112
|
+
"""Return a new entity whose observations all carry the given tag.
|
|
113
|
+
|
|
114
|
+
- Repeated texts are kept once, in first-seen order.
|
|
115
|
+
"""
|
|
116
|
+
unique_texts = dict.fromkeys(texts)
|
|
117
|
+
observations = [Observation(text, tag) for text in unique_texts]
|
|
118
|
+
return Entity(name, entity_type, observations, tag)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def look_entity(memory: Graph, name: str) -> Entity | None:
|
|
122
|
+
"""Return the entity with the given name, or None."""
|
|
123
|
+
for entity in memory.entities:
|
|
124
|
+
if entity.name == name:
|
|
125
|
+
return entity
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def create_entities(memory: Graph, entities: list[Entity]) -> list[Entity]:
|
|
130
|
+
"""Append entities whose names are new and return the ones appended."""
|
|
131
|
+
known_names = {entity.name for entity in memory.entities}
|
|
132
|
+
created: list[Entity] = []
|
|
133
|
+
for entity in entities:
|
|
134
|
+
if entity.name in known_names:
|
|
135
|
+
continue
|
|
136
|
+
known_names.add(entity.name)
|
|
137
|
+
memory.entities.append(entity)
|
|
138
|
+
created.append(entity)
|
|
139
|
+
return created
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _relation_key(relation: Relation) -> RelationKey:
|
|
143
|
+
return (relation.source, relation.target, relation.relation_type)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def look_missing_names(memory: Graph, relation: Relation) -> list[str]:
|
|
147
|
+
"""Return the relation's endpoint names that name no entity, once each."""
|
|
148
|
+
known_names = {entity.name for entity in memory.entities}
|
|
149
|
+
endpoints = dict.fromkeys((relation.source, relation.target))
|
|
150
|
+
return [name for name in endpoints if name not in known_names]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def create_relations(
|
|
154
|
+
memory: Graph, relations: list[Relation]
|
|
155
|
+
) -> list[Relation]:
|
|
156
|
+
"""Append relations that are new by content and return them.
|
|
157
|
+
|
|
158
|
+
- Two relations with the same endpoints and relation type are duplicates
|
|
159
|
+
even when their tags differ.
|
|
160
|
+
- Relations naming an entity that does not exist are skipped; see
|
|
161
|
+
`look_missing_names`.
|
|
162
|
+
"""
|
|
163
|
+
known_keys = {_relation_key(relation) for relation in memory.relations}
|
|
164
|
+
created: list[Relation] = []
|
|
165
|
+
for relation in relations:
|
|
166
|
+
key = _relation_key(relation)
|
|
167
|
+
if key in known_keys or look_missing_names(memory, relation):
|
|
168
|
+
continue
|
|
169
|
+
known_keys.add(key)
|
|
170
|
+
memory.relations.append(relation)
|
|
171
|
+
created.append(relation)
|
|
172
|
+
return created
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def update_observations(
|
|
176
|
+
memory: Graph, entity_name: str, texts: list[str], tag: Tag
|
|
177
|
+
) -> list[Observation]:
|
|
178
|
+
"""Append new observation texts to an entity and return the added ones.
|
|
179
|
+
|
|
180
|
+
- Texts already on the entity are skipped and keep their original tag.
|
|
181
|
+
- Raises `EntityNotFoundError` when the entity does not exist.
|
|
182
|
+
"""
|
|
183
|
+
entity = look_entity(memory, entity_name)
|
|
184
|
+
if entity is None:
|
|
185
|
+
message = f"Entity with name {entity_name} not found"
|
|
186
|
+
raise EntityNotFoundError(message)
|
|
187
|
+
known_texts = {observation.text for observation in entity.observations}
|
|
188
|
+
added: list[Observation] = []
|
|
189
|
+
for text in texts:
|
|
190
|
+
if text in known_texts:
|
|
191
|
+
continue
|
|
192
|
+
known_texts.add(text)
|
|
193
|
+
observation = Observation(text, tag)
|
|
194
|
+
entity.observations.append(observation)
|
|
195
|
+
added.append(observation)
|
|
196
|
+
return added
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _split[T](items: list[T], is_doomed: Callable[[T], bool]) -> list[T]:
|
|
200
|
+
"""Remove items matching `is_doomed` in place and return them."""
|
|
201
|
+
removed = [item for item in items if is_doomed(item)]
|
|
202
|
+
items[:] = [item for item in items if not is_doomed(item)]
|
|
203
|
+
return removed
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def remove_entities(
|
|
207
|
+
memory: Graph, names: list[str]
|
|
208
|
+
) -> tuple[list[Entity], list[Relation]]:
|
|
209
|
+
"""Remove the named entities and every relation that touches them.
|
|
210
|
+
|
|
211
|
+
- Returns the removed entities and relations; unknown names are ignored.
|
|
212
|
+
"""
|
|
213
|
+
doomed = set(names)
|
|
214
|
+
entities = _split(memory.entities, lambda e: e.name in doomed)
|
|
215
|
+
relations = _split(
|
|
216
|
+
memory.relations,
|
|
217
|
+
lambda r: r.source in doomed or r.target in doomed,
|
|
218
|
+
)
|
|
219
|
+
return entities, relations
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def remove_observations(
|
|
223
|
+
memory: Graph, entity_name: str, texts: list[str]
|
|
224
|
+
) -> list[Observation]:
|
|
225
|
+
"""Remove observations by text and return the removed ones.
|
|
226
|
+
|
|
227
|
+
- A missing entity or text is ignored.
|
|
228
|
+
"""
|
|
229
|
+
entity = look_entity(memory, entity_name)
|
|
230
|
+
if entity is None:
|
|
231
|
+
return []
|
|
232
|
+
doomed = set(texts)
|
|
233
|
+
return _split(entity.observations, lambda o: o.text in doomed)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def remove_relations(memory: Graph, keys: list[RelationKey]) -> list[Relation]:
|
|
237
|
+
"""Remove relations matching a key and return the removed ones.
|
|
238
|
+
|
|
239
|
+
- A key matches on endpoints and relation type, ignoring the tag.
|
|
240
|
+
"""
|
|
241
|
+
doomed = set(keys)
|
|
242
|
+
return _split(memory.relations, lambda r: _relation_key(r) in doomed)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _filter_to_entities(memory: Graph, entities: list[Entity]) -> Graph:
|
|
246
|
+
names = {entity.name for entity in entities}
|
|
247
|
+
relations = [
|
|
248
|
+
relation
|
|
249
|
+
for relation in memory.relations
|
|
250
|
+
if relation.source in names and relation.target in names
|
|
251
|
+
]
|
|
252
|
+
return Graph(entities, relations)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _is_query_match(entity: Entity, needle: str) -> bool:
|
|
256
|
+
if needle in entity.name.lower() or needle in entity.entity_type.lower():
|
|
257
|
+
return True
|
|
258
|
+
return any(
|
|
259
|
+
needle in observation.text.lower()
|
|
260
|
+
for observation in entity.observations
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def filter_by_query(memory: Graph, query: str) -> Graph:
|
|
265
|
+
"""Return entities matching a case-insensitive substring query.
|
|
266
|
+
|
|
267
|
+
- Matches entity name, entity type, and observation text.
|
|
268
|
+
- Keeps only relations whose both endpoints matched.
|
|
269
|
+
"""
|
|
270
|
+
needle = query.lower()
|
|
271
|
+
matches = [
|
|
272
|
+
entity for entity in memory.entities if _is_query_match(entity, needle)
|
|
273
|
+
]
|
|
274
|
+
return _filter_to_entities(memory, matches)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def filter_by_names(memory: Graph, names: list[str]) -> Graph:
|
|
278
|
+
"""Return the named entities and the relations between them."""
|
|
279
|
+
wanted = set(names)
|
|
280
|
+
matches = [entity for entity in memory.entities if entity.name in wanted]
|
|
281
|
+
return _filter_to_entities(memory, matches)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _count_sorted(names: list[str]) -> dict[str, int]:
|
|
285
|
+
counts: dict[str, int] = {}
|
|
286
|
+
for name in names:
|
|
287
|
+
counts[name] = counts.get(name, 0) + 1
|
|
288
|
+
return dict(sorted(counts.items()))
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def calc_usage(memory: Graph) -> Usage:
|
|
292
|
+
"""Return counts of entries, entity and relation types, and agents.
|
|
293
|
+
|
|
294
|
+
- Agents are counted over entities, observations, and relations;
|
|
295
|
+
untagged entries count as `untagged`.
|
|
296
|
+
"""
|
|
297
|
+
observations = [
|
|
298
|
+
observation
|
|
299
|
+
for entity in memory.entities
|
|
300
|
+
for observation in entity.observations
|
|
301
|
+
]
|
|
302
|
+
tags = [
|
|
303
|
+
*(entity.tag for entity in memory.entities),
|
|
304
|
+
*(observation.tag for observation in observations),
|
|
305
|
+
*(relation.tag for relation in memory.relations),
|
|
306
|
+
]
|
|
307
|
+
return {
|
|
308
|
+
"counts": {
|
|
309
|
+
"entities": len(memory.entities),
|
|
310
|
+
"observations": len(observations),
|
|
311
|
+
"relations": len(memory.relations),
|
|
312
|
+
},
|
|
313
|
+
"entity_types": _count_sorted(
|
|
314
|
+
[entity.entity_type for entity in memory.entities]
|
|
315
|
+
),
|
|
316
|
+
"relation_types": _count_sorted(
|
|
317
|
+
[relation.relation_type for relation in memory.relations]
|
|
318
|
+
),
|
|
319
|
+
"agents": _count_sorted([tag.agent or "untagged" for tag in tags]),
|
|
320
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Memory cleanup
|
|
2
|
+
|
|
3
|
+
Review this repo's plain-text-memory graph and propose a cleanup. Deleting
|
|
4
|
+
memory cannot be undone, so work in two phases: propose, then apply only
|
|
5
|
+
what I approve. A request to clean up is a request for a proposal, not
|
|
6
|
+
permission to delete.
|
|
7
|
+
|
|
8
|
+
## Phase 1: propose
|
|
9
|
+
|
|
10
|
+
1. Read the graph with `read_graph`.
|
|
11
|
+
1. For each candidate, check the repo before judging it. When two
|
|
12
|
+
observations contradict each other, the code decides which one is true,
|
|
13
|
+
not the newer `added_at`. When the repo cannot settle it, mark the item
|
|
14
|
+
"ask".
|
|
15
|
+
1. Present one table and stop:
|
|
16
|
+
|
|
17
|
+
| # | Entity | Item | Action | Evidence |
|
|
18
|
+
| --- | --- | --- | --- | --- |
|
|
19
|
+
| 1 | reading_list | "Tests run with unittest" | delete | `pyproject.toml` configures pytest |
|
|
20
|
+
| 2 | readinglist | whole entity | merge into reading_list | same repo, misspelled name |
|
|
21
|
+
|
|
22
|
+
Actions are `delete`, `merge into <entity>`, `rewrite`, `keep`, or `ask`.
|
|
23
|
+
Entries marked "safe to delete" or starting with `TEST:` are ordinary
|
|
24
|
+
`delete` rows. They still wait for my approval.
|
|
25
|
+
|
|
26
|
+
## Phase 2: apply
|
|
27
|
+
|
|
28
|
+
Apply only the rows I approve, then list what changed. The delete tools
|
|
29
|
+
return everything they deleted.
|
|
30
|
+
|
|
31
|
+
A merge re-adds observations with `add_observations`, which stamps them with
|
|
32
|
+
today's date and the current agent; the originals' tags are lost. Say so in
|
|
33
|
+
the merge row so I can choose `keep` instead.
|
|
34
|
+
|
|
35
|
+
Go back to phase 1 instead of deleting anything I have not seen in the
|
|
36
|
+
table, settling a contradiction by date alone, or treating "safe to delete"
|
|
37
|
+
or empty tags as approval.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Memory export
|
|
2
|
+
|
|
3
|
+
Export this repo's plain-text-memory taxonomy.
|
|
4
|
+
|
|
5
|
+
1. Call `export_taxonomy`. It writes `.agents/memory.local.taxonomy.jsonc`
|
|
6
|
+
beside the memory file, holding the server's versioned taxonomy and this
|
|
7
|
+
repo's usage: entry counts, entity and relation types in use, and entries
|
|
8
|
+
per agent. Running it again overwrites the file with current data.
|
|
9
|
+
1. Confirm `.gitignore` ignores the export with the line
|
|
10
|
+
`.agents/*memory.local.*`; replace an older `.agents/*memory.local.jsonl*`
|
|
11
|
+
line with it. Add it if missing and tell me.
|
|
12
|
+
1. Report in two or three lines: the file path, `taxonomy_version`, and the
|
|
13
|
+
entity, observation, and relation counts.
|
|
14
|
+
1. If two entity or relation types in the file look like the same thing
|
|
15
|
+
spelled differently, such as `library` and `libraries`, name them and
|
|
16
|
+
suggest the cleanup prompt. Change nothing yourself.
|
|
17
|
+
|
|
18
|
+
If the tool reports that the memory file belongs to another repo, resolve
|
|
19
|
+
that with `initialize_memory` first, then export again. A repo with no
|
|
20
|
+
memory yet still exports, with zero counts.
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Map graph models to and from JSONL records.
|
|
2
|
+
|
|
3
|
+
- Key names match the original server where they overlap: `entityType`,
|
|
4
|
+
`relationType`, `from`, and `to`.
|
|
5
|
+
- Records written before tagging existed load with `UNTAGGED`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Literal, TypedDict
|
|
11
|
+
|
|
12
|
+
from plain_text_memory_mcp.graph import (
|
|
13
|
+
UNTAGGED,
|
|
14
|
+
Entity,
|
|
15
|
+
Graph,
|
|
16
|
+
Header,
|
|
17
|
+
Observation,
|
|
18
|
+
Relation,
|
|
19
|
+
Tag,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
Record = dict[str, object]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ObservationRecord(TypedDict):
|
|
26
|
+
"""One observation as JSON, inside an entity's observations list."""
|
|
27
|
+
|
|
28
|
+
text: str
|
|
29
|
+
added_at: str | None
|
|
30
|
+
agent: str | None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class EntityRecord(TypedDict):
|
|
34
|
+
"""One entity line of the memory file."""
|
|
35
|
+
|
|
36
|
+
type: Literal["entity"]
|
|
37
|
+
name: str
|
|
38
|
+
entityType: str
|
|
39
|
+
added_at: str | None
|
|
40
|
+
agent: str | None
|
|
41
|
+
observations: list[ObservationRecord]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Functional form: "from" is a Python keyword.
|
|
45
|
+
RelationRecord = TypedDict(
|
|
46
|
+
"RelationRecord",
|
|
47
|
+
{
|
|
48
|
+
"type": Literal["relation"],
|
|
49
|
+
"from": str,
|
|
50
|
+
"to": str,
|
|
51
|
+
"relationType": str,
|
|
52
|
+
"added_at": str | None,
|
|
53
|
+
"agent": str | None,
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
RelationRecord.__doc__ = "One relation line of the memory file."
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class HeaderRecord(TypedDict):
|
|
60
|
+
"""The header line naming the repo that owns the memory file."""
|
|
61
|
+
|
|
62
|
+
type: Literal["header"]
|
|
63
|
+
repo_name: str
|
|
64
|
+
repo_root: str
|
|
65
|
+
added_at: str | None
|
|
66
|
+
agent: str | None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class GraphRecord(TypedDict):
|
|
70
|
+
"""Entities and the relations between them, as tools return them."""
|
|
71
|
+
|
|
72
|
+
entities: list[EntityRecord]
|
|
73
|
+
relations: list[RelationRecord]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# What each record type is for; published in the taxonomy and its export.
|
|
77
|
+
RECORD_PURPOSES = {
|
|
78
|
+
"header": (
|
|
79
|
+
"First line of the memory file. Names the repo that owns it, so a"
|
|
80
|
+
" copy in another repo is detected."
|
|
81
|
+
),
|
|
82
|
+
"entity": (
|
|
83
|
+
"A named thing the agents remember, such as a repo, module, library,"
|
|
84
|
+
" person, or decision, with its observations."
|
|
85
|
+
),
|
|
86
|
+
"relation": (
|
|
87
|
+
"A directed link between two entities, in active voice:"
|
|
88
|
+
" reading_list uses sqlite."
|
|
89
|
+
),
|
|
90
|
+
"observation": (
|
|
91
|
+
"One fact about an entity, stored inside the entity's observations"
|
|
92
|
+
" list."
|
|
93
|
+
),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class RecordError(ValueError):
|
|
98
|
+
"""Raised when a memory record does not match the expected shape."""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def to_observation_record(observation: Observation) -> ObservationRecord:
|
|
102
|
+
"""Return the JSON record for one observation."""
|
|
103
|
+
return {
|
|
104
|
+
"text": observation.text,
|
|
105
|
+
"added_at": observation.tag.added_at,
|
|
106
|
+
"agent": observation.tag.agent,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def to_entity_record(entity: Entity) -> EntityRecord:
|
|
111
|
+
"""Return the JSON record for one entity and its observations."""
|
|
112
|
+
return {
|
|
113
|
+
"type": "entity",
|
|
114
|
+
"name": entity.name,
|
|
115
|
+
"entityType": entity.entity_type,
|
|
116
|
+
"added_at": entity.tag.added_at,
|
|
117
|
+
"agent": entity.tag.agent,
|
|
118
|
+
"observations": [
|
|
119
|
+
to_observation_record(observation)
|
|
120
|
+
for observation in entity.observations
|
|
121
|
+
],
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def to_relation_record(relation: Relation) -> RelationRecord:
|
|
126
|
+
"""Return the JSON record for one relation."""
|
|
127
|
+
return {
|
|
128
|
+
"type": "relation",
|
|
129
|
+
"from": relation.source,
|
|
130
|
+
"to": relation.target,
|
|
131
|
+
"relationType": relation.relation_type,
|
|
132
|
+
"added_at": relation.tag.added_at,
|
|
133
|
+
"agent": relation.tag.agent,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def to_header_record(header: Header) -> HeaderRecord:
|
|
138
|
+
"""Return the JSON record for the memory file's owning repo."""
|
|
139
|
+
return {
|
|
140
|
+
"type": "header",
|
|
141
|
+
"repo_name": header.repo_name,
|
|
142
|
+
"repo_root": header.repo_root,
|
|
143
|
+
"added_at": header.tag.added_at,
|
|
144
|
+
"agent": header.tag.agent,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def to_graph_record(memory: Graph) -> GraphRecord:
|
|
149
|
+
"""Return a JSON record holding every entity and relation."""
|
|
150
|
+
return {
|
|
151
|
+
"entities": [to_entity_record(entity) for entity in memory.entities],
|
|
152
|
+
"relations": [
|
|
153
|
+
to_relation_record(relation) for relation in memory.relations
|
|
154
|
+
],
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _parse_tag(raw: dict[str, object]) -> Tag:
|
|
159
|
+
added_at = raw.get("added_at")
|
|
160
|
+
agent = raw.get("agent")
|
|
161
|
+
if added_at is None and agent is None:
|
|
162
|
+
return UNTAGGED
|
|
163
|
+
return Tag(
|
|
164
|
+
added_at if isinstance(added_at, str) else None,
|
|
165
|
+
agent if isinstance(agent, str) else None,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _parse_text(raw: dict[str, object], key: str) -> str:
|
|
170
|
+
value = raw.get(key)
|
|
171
|
+
if not isinstance(value, str):
|
|
172
|
+
message = f"field {key!r} must be a string, found {value!r}"
|
|
173
|
+
raise RecordError(message)
|
|
174
|
+
return value
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _parse_observation(raw: object) -> Observation:
|
|
178
|
+
if isinstance(raw, str):
|
|
179
|
+
return Observation(raw, UNTAGGED)
|
|
180
|
+
if isinstance(raw, dict):
|
|
181
|
+
return Observation(_parse_text(raw, "text"), _parse_tag(raw))
|
|
182
|
+
message = f"observation must be a string or object, found {raw!r}"
|
|
183
|
+
raise RecordError(message)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _parse_entity(raw: dict[str, object]) -> Entity:
|
|
187
|
+
observations = raw.get("observations", [])
|
|
188
|
+
if not isinstance(observations, list):
|
|
189
|
+
message = f"observations must be a list, found {observations!r}"
|
|
190
|
+
raise RecordError(message)
|
|
191
|
+
return Entity(
|
|
192
|
+
_parse_text(raw, "name"),
|
|
193
|
+
_parse_text(raw, "entityType"),
|
|
194
|
+
[_parse_observation(observation) for observation in observations],
|
|
195
|
+
_parse_tag(raw),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _parse_relation(raw: dict[str, object]) -> Relation:
|
|
200
|
+
return Relation(
|
|
201
|
+
_parse_text(raw, "from"),
|
|
202
|
+
_parse_text(raw, "to"),
|
|
203
|
+
_parse_text(raw, "relationType"),
|
|
204
|
+
_parse_tag(raw),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _parse_header(raw: dict[str, object]) -> Header:
|
|
209
|
+
return Header(
|
|
210
|
+
_parse_text(raw, "repo_name"),
|
|
211
|
+
_parse_text(raw, "repo_root"),
|
|
212
|
+
_parse_tag(raw),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def parse_record(raw: object) -> Entity | Relation | Header:
|
|
217
|
+
"""Parse one decoded JSON line into an entity, relation, or header.
|
|
218
|
+
|
|
219
|
+
- Accepts legacy untagged records and plain-string observations.
|
|
220
|
+
- Raises `RecordError` for any other shape.
|
|
221
|
+
"""
|
|
222
|
+
if not isinstance(raw, dict):
|
|
223
|
+
message = f"record must be a JSON object, found {raw!r}"
|
|
224
|
+
raise RecordError(message)
|
|
225
|
+
kind = raw.get("type")
|
|
226
|
+
if kind == "entity":
|
|
227
|
+
return _parse_entity(raw)
|
|
228
|
+
if kind == "relation":
|
|
229
|
+
return _parse_relation(raw)
|
|
230
|
+
if kind == "header":
|
|
231
|
+
return _parse_header(raw)
|
|
232
|
+
message = f"unknown record type {kind!r}"
|
|
233
|
+
raise RecordError(message)
|