hotmemory 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.
hotmemory/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """Agent memory as tables on Hotdata."""
2
+
3
+ from hotmemory.contract import Fact, Memory
4
+ from hotmemory.filter import Filter, TimeRange
5
+ from hotmemory.memory import MemoryStore, MemoryWriter
6
+ from hotmemory.record import SCHEMA_VERSION, JSONValue, Kind, Record, normalize
7
+ from hotmemory.store import Clock, Embedder, Hit, Store, Writer
8
+
9
+ __all__ = [
10
+ "SCHEMA_VERSION",
11
+ "Clock",
12
+ "Embedder",
13
+ "Fact",
14
+ "Filter",
15
+ "Hit",
16
+ "JSONValue",
17
+ "Kind",
18
+ "Memory",
19
+ "MemoryStore",
20
+ "MemoryWriter",
21
+ "Record",
22
+ "Store",
23
+ "TimeRange",
24
+ "Writer",
25
+ "normalize",
26
+ ]
hotmemory/_chunks.py ADDED
@@ -0,0 +1,184 @@
1
+ """The chunking of a Markdown document for `Memory.load`, and of a text for `Memory.capture`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ _HEADING = re.compile(r"^ {0,3}(#{1,6})(?:[ \t]+|$)")
9
+ _FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})")
10
+ _TABLE_DELIMITER = re.compile(r"^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$")
11
+ PATH_SEPARATOR = " > "
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Chunk:
16
+ """One chunk of a document: the heading path of its first line, and its text."""
17
+
18
+ path: tuple[str, ...]
19
+ text: str
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class _Line:
24
+ text: str
25
+ level: int
26
+ fenced: bool
27
+
28
+
29
+ def chunk_document(text: str, chunk_chars: int) -> list[str]:
30
+ """Return the chunks of the Markdown `text`, each with its heading prefix.
31
+
32
+ A chunk is `<prefix>\\n\\n<body>`. The prefix is the first heading of the document,
33
+ then the heading path of the chunk's first line, joined by ` > `, with a heading that
34
+ repeats the one before it left out. A document with no heading gives chunks with no
35
+ prefix. The body holds at most `chunk_chars` characters, unless one line is longer.
36
+ A document of blank lines only gives no chunk.
37
+ """
38
+ lines = _scan(text)
39
+ title = next((line.text.strip() for line in lines if line.level), "")
40
+ chunks = []
41
+ for chunk in pack(lines, chunk_chars):
42
+ parts: list[str] = []
43
+ for heading in (title, *chunk.path):
44
+ if heading and (not parts or parts[-1] != heading):
45
+ parts.append(heading)
46
+ prefix = PATH_SEPARATOR.join(parts)
47
+ chunks.append(f"{prefix}\n\n{chunk.text}" if prefix else chunk.text)
48
+ return chunks
49
+
50
+
51
+ def chunk_text(text: str, chunk_chars: int) -> list[str]:
52
+ """Return the chunks of `text`, cut at blank lines outside fences, with no prefix.
53
+
54
+ A heading line does not start a section. A block that does not fit is cut at line
55
+ ends, and a line that does not fit is cut at `chunk_chars`. The blocks are packed in
56
+ order into bodies of at most `chunk_chars`, joined by a blank line. A text of blank
57
+ lines only gives no chunk.
58
+ """
59
+ lines = [_Line(line.text, 0, line.fenced) for line in _scan(text)]
60
+ return [chunk.text for chunk in pack(lines, chunk_chars)]
61
+
62
+
63
+ def pack(lines: list[_Line], chunk_chars: int) -> list[Chunk]:
64
+ """Return the sections of `lines`, cut and packed into bodies of at most `chunk_chars`.
65
+
66
+ The sections start at each heading outside a fence. A section that does not fit is
67
+ cut into blocks at blank lines outside fences, and its heading stays with the block
68
+ after it when both fit. A block that does not fit is cut at
69
+ line ends, and a line that does not fit is cut at `chunk_chars`. A cut pipe table
70
+ repeats its header row and delimiter row at the top of each later piece. Then the
71
+ pieces are packed in order, joined by a blank line.
72
+ """
73
+ pieces: list[Chunk] = []
74
+ for path, section in _sections(lines):
75
+ body = _join(section)
76
+ if not body:
77
+ continue
78
+ if len(body) <= chunk_chars:
79
+ pieces.append(Chunk(path, body))
80
+ continue
81
+ blocks = _blocks(section)
82
+ if len(blocks) > 1 and section[0].level and len(blocks[0]) == 1:
83
+ joined = [*blocks[0], "", *blocks[1]]
84
+ if len("\n".join(joined)) <= chunk_chars:
85
+ blocks[:2] = [joined]
86
+ for block in blocks:
87
+ pieces.extend(Chunk(path, piece) for piece in _cut_block(block, chunk_chars))
88
+ packed: list[Chunk] = []
89
+ for piece in pieces:
90
+ if packed and len(packed[-1].text) + 2 + len(piece.text) <= chunk_chars:
91
+ packed[-1] = Chunk(packed[-1].path, f"{packed[-1].text}\n\n{piece.text}")
92
+ else:
93
+ packed.append(piece)
94
+ return packed
95
+
96
+
97
+ def _scan(text: str) -> list[_Line]:
98
+ """Return each line of `text` with its heading level, 0 for none, and its fence state."""
99
+ lines = []
100
+ fence = ""
101
+ for raw in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
102
+ opener = _FENCE.match(raw)
103
+ if fence:
104
+ marker = opener.group(1) if opener is not None else ""
105
+ if marker[:1] == fence[0] and len(marker) >= len(fence) and not raw.strip(" `~"):
106
+ fence = ""
107
+ lines.append(_Line(raw, 0, True))
108
+ continue
109
+ if opener is not None:
110
+ fence = opener.group(1)
111
+ lines.append(_Line(raw, 0, True))
112
+ continue
113
+ heading = _HEADING.match(raw)
114
+ lines.append(_Line(raw, len(heading.group(1)) if heading else 0, False))
115
+ return lines
116
+
117
+
118
+ def _sections(lines: list[_Line]) -> list[tuple[tuple[str, ...], list[_Line]]]:
119
+ """Return the sections of `lines`, each with the heading path of its heading."""
120
+ sections: list[tuple[tuple[str, ...], list[_Line]]] = [((), [])]
121
+ stack: list[tuple[int, str]] = []
122
+ for line in lines:
123
+ if line.level:
124
+ while stack and stack[-1][0] >= line.level:
125
+ stack.pop()
126
+ stack.append((line.level, line.text.strip()))
127
+ sections.append((tuple(text for _, text in stack), []))
128
+ sections[-1][1].append(line)
129
+ return sections
130
+
131
+
132
+ def _blocks(lines: list[_Line]) -> list[list[str]]:
133
+ """Return the runs of lines between blank lines outside fences."""
134
+ blocks: list[list[str]] = [[]]
135
+ for line in lines:
136
+ if not line.fenced and not line.text.strip():
137
+ if blocks[-1]:
138
+ blocks.append([])
139
+ continue
140
+ blocks[-1].append(line.text)
141
+ return [block for block in blocks if block]
142
+
143
+
144
+ def _cut_block(block: list[str], chunk_chars: int) -> list[str]:
145
+ """Return `block` cut at line ends into pieces of at most `chunk_chars` characters.
146
+
147
+ A line longer than `chunk_chars` is cut at that size. A pipe table repeats its first
148
+ two rows at the top of each piece after the first, when they leave room for a row.
149
+ """
150
+ header: list[str] = []
151
+ if len(block) > 2 and block[0].lstrip().startswith("|") and _TABLE_DELIMITER.match(block[1]):
152
+ header = block[:2]
153
+ if len("\n".join(header)) * 2 >= chunk_chars:
154
+ header = []
155
+ pieces: list[str] = []
156
+ current: list[str] = []
157
+ for line in block:
158
+ for part in _cut_line(line, chunk_chars):
159
+ if current and len("\n".join([*current, part])) > chunk_chars:
160
+ pieces.append("\n".join(current))
161
+ current = list(header) if header and part.lstrip().startswith("|") else []
162
+ if current and len("\n".join([*current, part])) > chunk_chars:
163
+ current = []
164
+ current.append(part)
165
+ if current:
166
+ pieces.append("\n".join(current))
167
+ return pieces
168
+
169
+
170
+ def _cut_line(line: str, chunk_chars: int) -> list[str]:
171
+ """Return `line` cut into parts of at most `chunk_chars` characters."""
172
+ if len(line) <= chunk_chars:
173
+ return [line]
174
+ return [line[start : start + chunk_chars] for start in range(0, len(line), chunk_chars)]
175
+
176
+
177
+ def _join(lines: list[_Line]) -> str:
178
+ """Return the text of `lines` without the blank lines at either end."""
179
+ texts = [line.text for line in lines]
180
+ while texts and not texts[0].strip():
181
+ texts.pop(0)
182
+ while texts and not texts[-1].strip():
183
+ texts.pop()
184
+ return "\n".join(texts)
hotmemory/_rules.py ADDED
@@ -0,0 +1,196 @@
1
+ """Plain functions that every driver calls, so the drivers agree on the contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Sequence
7
+ from dataclasses import dataclass, replace
8
+ from datetime import datetime
9
+
10
+ from hotmemory.filter import Filter, kinds_of
11
+ from hotmemory.record import JSONValue, Kind, Record, check_label, normalize
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Draft:
16
+ """One put before a driver writes it: the record, and whether it closes the current one."""
17
+
18
+ record: Record
19
+ close_previous: bool = False
20
+
21
+
22
+ def check_prefix(prefix: Sequence[str]) -> tuple[str, ...]:
23
+ """Return `prefix` as a tuple of labels. An empty prefix is allowed and matches all."""
24
+ if isinstance(prefix, str):
25
+ raise TypeError("prefix must be a sequence of labels, not a string")
26
+ labels = tuple(prefix)
27
+ for label in labels:
28
+ check_label(label)
29
+ return labels
30
+
31
+
32
+ def under_prefix(namespace: tuple[str, ...], prefix: tuple[str, ...]) -> bool:
33
+ """Return True if the first labels of `namespace` are the labels of `prefix`."""
34
+ return namespace[: len(prefix)] == prefix
35
+
36
+
37
+ def next_revision(current: Record | None) -> int:
38
+ """Return the revision number for the next put after `current`."""
39
+ return 1 if current is None else current.revision + 1
40
+
41
+
42
+ def check_episode_line(current: Record | None, kind: Kind) -> None:
43
+ """Raise ValueError if a put of `kind` moves the key of `current` across the episode line.
44
+
45
+ A key holds episodes only, or holds no episode at all.
46
+ """
47
+ if current is not None and (current.kind == "episode") != (kind == "episode"):
48
+ raise ValueError(
49
+ f"key {current.key!r} holds kind {current.kind!r}; a put of kind {kind!r} "
50
+ "cannot move a key between 'episode' and another kind"
51
+ )
52
+
53
+
54
+ def is_duplicate(current: Record, content: str, now: datetime) -> bool:
55
+ """Return True if a put of `content` writes nothing because `current` holds it.
56
+
57
+ That is the case when `current` is listed at `now` and its normalized content equals
58
+ the normalized `content`. A current revision past its `forget_after` is never a
59
+ duplicate, so a put brings the fact back as a new revision.
60
+ """
61
+ return is_listed(current, now) and normalize(current.content) == normalize(content)
62
+
63
+
64
+ def check_close(current: Record, record: Record) -> None:
65
+ """Raise ValueError if a put with `close_previous` cannot close `current` for `record`.
66
+
67
+ The new `valid_from` must be set, and must not be earlier than the `valid_from` of
68
+ `current`.
69
+ """
70
+ if record.valid_from is None:
71
+ raise ValueError("close_previous needs valid_from or observed_at on the new revision")
72
+ if current.valid_from is not None and current.valid_from > record.valid_from:
73
+ raise ValueError(
74
+ f"key {current.key!r} is valid from {current.valid_from.isoformat()}, which is "
75
+ f"later than the new valid_from {record.valid_from.isoformat()}"
76
+ )
77
+
78
+
79
+ def superseded(current: Record, record: Record, now: datetime, close_previous: bool) -> Record:
80
+ """Return `current` with `superseded_by` set to the id of `record`.
81
+
82
+ With `close_previous`, its `valid_until` also becomes the `valid_from` of `record`, and
83
+ its `expired_at` becomes `now`.
84
+ """
85
+ if not close_previous:
86
+ return replace(current, superseded_by=record.id)
87
+ return replace(current, superseded_by=record.id, valid_until=record.valid_from, expired_at=now)
88
+
89
+
90
+ def with_new_sources(current: Record, record: Record) -> Record | None:
91
+ """Return `record` with the sources of `current` and then its own, or None if none is new.
92
+
93
+ A put whose content duplicates `current` calls this. The merged sources keep their
94
+ order and hold no repeats.
95
+ """
96
+ sources = tuple(dict.fromkeys((*current.sources, *record.sources)))
97
+ if sources == current.sources:
98
+ return None
99
+ return replace(record, sources=sources)
100
+
101
+
102
+ def is_listed(record: Record, now: datetime) -> bool:
103
+ """Return True if `list` and `search` can return `record` at `now`.
104
+
105
+ A superseded revision is not listed. A record is not listed once its `forget_after`
106
+ is at or before `now`.
107
+ """
108
+ return record.superseded_by is None and not is_forgotten(record, now)
109
+
110
+
111
+ def is_forgotten(record: Record, now: datetime) -> bool:
112
+ """Return True if `record` is past its `forget_after` at `now`."""
113
+ return record.forget_after is not None and record.forget_after <= now
114
+
115
+
116
+ def matches(record: Record, filter: Filter | None) -> bool:
117
+ """Return True if `record` satisfies every field that `filter` sets."""
118
+ if filter is None:
119
+ return True
120
+ kinds = kinds_of(filter)
121
+ if kinds is not None and record.kind not in kinds:
122
+ return False
123
+ if filter.subject is not None and record.subject != filter.subject:
124
+ return False
125
+ if filter.actor is not None and record.actor != filter.actor:
126
+ return False
127
+ if filter.tags is not None and not set(filter.tags) <= set(record.tags):
128
+ return False
129
+ ranges = (
130
+ (filter.valid_from, record.valid_from),
131
+ (filter.valid_until, record.valid_until),
132
+ (filter.created_at, record.created_at),
133
+ (filter.expired_at, record.expired_at),
134
+ )
135
+ return all(window is None or window.contains(value) for window, value in ranges)
136
+
137
+
138
+ def newest_first(record: Record) -> tuple[float, str]:
139
+ """Sort key for the order of `list`: newest `created_at` first, then by id."""
140
+ return (-record.created_at.timestamp(), record.id)
141
+
142
+
143
+ def build_record(
144
+ namespace: Sequence[str],
145
+ key: str,
146
+ revision: int,
147
+ created_at: datetime,
148
+ *,
149
+ kind: Kind,
150
+ content: str,
151
+ subject: str,
152
+ cues: Sequence[str],
153
+ payload: dict[str, JSONValue] | None,
154
+ tags: Sequence[str],
155
+ sources: Sequence[str],
156
+ actor: str,
157
+ observed_at: datetime | None,
158
+ valid_from: datetime | None,
159
+ valid_until: datetime | None,
160
+ forget_after: datetime | None,
161
+ forget_reason: str,
162
+ ) -> Record:
163
+ """Return the record that a put writes. `valid_from` defaults to `observed_at`."""
164
+ return Record(
165
+ namespace=tuple(namespace),
166
+ key=key,
167
+ revision=revision,
168
+ kind=kind,
169
+ subject=subject,
170
+ content=content,
171
+ cues=tuple(cues),
172
+ payload={} if payload is None else payload,
173
+ tags=tuple(tags),
174
+ sources=tuple(sources),
175
+ actor=actor,
176
+ created_at=created_at,
177
+ observed_at=observed_at,
178
+ valid_from=observed_at if valid_from is None else valid_from,
179
+ valid_until=valid_until,
180
+ forget_after=forget_after,
181
+ forget_reason=forget_reason,
182
+ )
183
+
184
+
185
+ def cosine_distance(a: Sequence[float], b: Sequence[float]) -> float:
186
+ """Return 1 minus the cosine similarity of `a` and `b`.
187
+
188
+ Raises ValueError if the lengths differ or a vector has zero length.
189
+ """
190
+ if len(a) != len(b):
191
+ raise ValueError(f"vectors differ in length: {len(a)} and {len(b)}")
192
+ norm_a = math.sqrt(sum(x * x for x in a))
193
+ norm_b = math.sqrt(sum(x * x for x in b))
194
+ if norm_a == 0 or norm_b == 0:
195
+ raise ValueError("cannot compute a cosine distance for a zero vector")
196
+ return 1.0 - sum(x * y for x, y in zip(a, b, strict=True)) / (norm_a * norm_b)
hotmemory/_writer.py ADDED
@@ -0,0 +1,115 @@
1
+ """The buffer behind every driver's `Writer`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import builtins
6
+ from collections.abc import Callable, Sequence
7
+ from datetime import datetime, timedelta
8
+ from types import TracebackType
9
+ from typing import Self
10
+
11
+ from hotmemory._rules import Draft, build_record
12
+ from hotmemory.record import JSONValue, Kind
13
+ from hotmemory.store import Clock
14
+
15
+
16
+ def check_count(name: str, value: int) -> None:
17
+ """Raise TypeError if `value` is not an integer, or ValueError if it is negative."""
18
+ if isinstance(value, bool) or not isinstance(value, int):
19
+ raise TypeError(f"{name} must be an integer")
20
+ if value < 0:
21
+ raise ValueError(f"{name} must not be negative")
22
+
23
+
24
+ class BufferedWriter:
25
+ """A `Writer` that hands its buffer to `write` in one call on each flush.
26
+
27
+ `write` takes the buffered drafts in order and returns the id that each one wrote.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ write: Callable[[Sequence[Draft]], builtins.list[str]],
33
+ clock: Clock,
34
+ max_rows: int,
35
+ interval: timedelta,
36
+ ) -> None:
37
+ check_count("max_rows", max_rows)
38
+ if max_rows < 1:
39
+ raise ValueError("max_rows must be 1 or more")
40
+ self._write = write
41
+ self._clock = clock
42
+ self._max_rows = max_rows
43
+ self._interval = interval
44
+ self._buffer: builtins.list[Draft] = []
45
+ self._flushed: builtins.list[str] = []
46
+ self._last_flush = clock()
47
+
48
+ @property
49
+ def flushed(self) -> builtins.list[str]:
50
+ return builtins.list(self._flushed)
51
+
52
+ def put(
53
+ self,
54
+ namespace: Sequence[str],
55
+ key: str,
56
+ *,
57
+ kind: Kind,
58
+ content: str,
59
+ subject: str = "",
60
+ cues: Sequence[str] = (),
61
+ payload: dict[str, JSONValue] | None = None,
62
+ tags: Sequence[str] = (),
63
+ sources: Sequence[str] = (),
64
+ actor: str = "",
65
+ observed_at: datetime | None = None,
66
+ valid_from: datetime | None = None,
67
+ valid_until: datetime | None = None,
68
+ forget_after: datetime | None = None,
69
+ forget_reason: str = "",
70
+ close_previous: bool = False,
71
+ ) -> None:
72
+ now = self._clock()
73
+ record = build_record(
74
+ namespace,
75
+ key,
76
+ 1,
77
+ now,
78
+ kind=kind,
79
+ content=content,
80
+ subject=subject,
81
+ cues=cues,
82
+ payload=payload,
83
+ tags=tags,
84
+ sources=sources,
85
+ actor=actor,
86
+ observed_at=observed_at,
87
+ valid_from=valid_from,
88
+ valid_until=valid_until,
89
+ forget_after=forget_after,
90
+ forget_reason=forget_reason,
91
+ )
92
+ self._buffer.append(Draft(record, close_previous))
93
+ if len(self._buffer) >= self._max_rows or now - self._last_flush >= self._interval:
94
+ self.flush()
95
+
96
+ def flush(self) -> builtins.list[str]:
97
+ ids = self._write(self._buffer) if self._buffer else []
98
+ self._buffer.clear()
99
+ self._flushed.extend(ids)
100
+ self._last_flush = self._clock()
101
+ return ids
102
+
103
+ def __enter__(self) -> Self:
104
+ return self
105
+
106
+ def __exit__(
107
+ self,
108
+ exc_type: type[BaseException] | None,
109
+ exc: BaseException | None,
110
+ traceback: TracebackType | None,
111
+ ) -> None:
112
+ if exc_type is None:
113
+ self.flush()
114
+ else:
115
+ self._buffer.clear()