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.
@@ -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)