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/contract.py ADDED
@@ -0,0 +1,645 @@
1
+ """The memory contract: `Memory`, the operations an agent calls, over one `Store`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ from collections.abc import Callable, Sequence
8
+ from dataclasses import dataclass, field, replace
9
+ from datetime import UTC, datetime, timedelta
10
+ from typing import Literal, TypeAlias
11
+
12
+ from hotmemory._chunks import chunk_document, chunk_text
13
+ from hotmemory._rules import (
14
+ check_prefix,
15
+ is_duplicate,
16
+ is_forgotten,
17
+ newest_first,
18
+ next_revision,
19
+ under_prefix,
20
+ )
21
+ from hotmemory._writer import check_count
22
+ from hotmemory.filter import Filter
23
+ from hotmemory.memory import utc_now
24
+ from hotmemory.record import (
25
+ KINDS,
26
+ JSONValue,
27
+ Kind,
28
+ Record,
29
+ check_key,
30
+ check_namespace,
31
+ normalize,
32
+ record_id,
33
+ )
34
+ from hotmemory.store import Clock, Hit, Store
35
+
36
+ SUBJECT_LENGTH = 64
37
+ HASH_LENGTH = 16
38
+ DEFAULT_BUDGET = 2000
39
+ DEFAULT_CHUNK_CHARS = 2000
40
+ FORGET_SCAN = 10_000
41
+ PROFILE_LIMIT = 100
42
+ COUNT_LIMIT = 1000
43
+ _NOT_IN_KEY = re.compile(r"[^A-Za-z0-9_-]")
44
+ _CHUNK_ID = re.compile(r"^(.+)-([0-9]{4})@([0-9]+)$")
45
+ RANGE_MARK = "\u2026"
46
+ RECALLED_KINDS: tuple[Kind, ...] = ("fact", "profile", "procedure")
47
+ EPISODES_LINE = "episodes:"
48
+ Episodes: TypeAlias = Literal["cited", "search"]
49
+ """Which episodes `recall` adds after the facts: those the facts cite, or a search over them."""
50
+
51
+
52
+ @dataclass(frozen=True, kw_only=True)
53
+ class Fact:
54
+ """One structured fact for `Memory.remember`: `kind`, `content`, and optional fields.
55
+
56
+ The fields are those of `Record` that a caller sets. The store checks them on write.
57
+ """
58
+
59
+ kind: Kind
60
+ content: str
61
+ subject: str = ""
62
+ cues: tuple[str, ...] = ()
63
+ payload: dict[str, JSONValue] = field(default_factory=dict)
64
+ tags: tuple[str, ...] = ()
65
+ sources: tuple[str, ...] = ()
66
+ observed_at: datetime | None = None
67
+ valid_from: datetime | None = None
68
+ valid_until: datetime | None = None
69
+ forget_after: datetime | None = None
70
+ forget_reason: str = ""
71
+
72
+
73
+ Extractor: TypeAlias = Callable[[str, datetime | None, list[Record]], Sequence[Fact]]
74
+ """Takes the text, `observed_at`, and the current records, and returns the facts to remember."""
75
+
76
+
77
+ def is_valid_at(record: Record, when: datetime) -> bool:
78
+ """Return True if `record` is valid at `when` by the as-of rule.
79
+
80
+ `valid_from` is null or at most `when`, and `valid_until` is null or after `when`.
81
+ """
82
+ starts = record.valid_from is None or record.valid_from <= when
83
+ ends = record.valid_until is None or record.valid_until > when
84
+ return starts and ends
85
+
86
+
87
+ def render_line(record: Record) -> str:
88
+ """Return the line of `record` in a rendered block.
89
+
90
+ The line is `- <content> [sources: a, b] [valid: <from> to <until>]`. Each run of
91
+ whitespace in the content becomes one space. A null `valid_from` shows as `unknown`,
92
+ a null `valid_until` as `now`, and no sources as `none`. A source that is the id of a
93
+ record in the namespace of `record` shows as `<key>@<revision>`. A run of two or more
94
+ such sources, next to each other, with keys `<prefix>-<n>` of one prefix, n in four
95
+ digits counting up by 1, and one revision, shows as `<prefix>-<first>…<last>@<revision>`.
96
+ """
97
+ content = " ".join(record.content.split())
98
+ sources = ", ".join(_shown_sources(record.sources, record.namespace))
99
+ sources = sources or "none"
100
+ start = _time(record.valid_from, "unknown")
101
+ end = _time(record.valid_until, "now")
102
+ return f"- {content} [sources: {sources}] [valid: {start} to {end}]"
103
+
104
+
105
+ def render_episode(record: Record) -> str:
106
+ """Return the part of a rendered block for the episode `record`.
107
+
108
+ The part is a header `## <key>@<revision> [observed: <observed_at>]`, a newline, and
109
+ the content with its line breaks. A null `observed_at` shows as `unknown`.
110
+ """
111
+ observed = _time(record.observed_at, "unknown")
112
+ return f"## {record.key}@{record.revision} [observed: {observed}]\n{record.content}"
113
+
114
+
115
+ def fit_lines(lines: Sequence[str], budget: int) -> int:
116
+ """Return how many of `lines`, from the first, fit in `budget` characters with newlines.
117
+
118
+ The count stops at the first line that would pass the budget.
119
+ """
120
+ used = 0
121
+ for count, line in enumerate(lines):
122
+ used += len(line) + (1 if count else 0)
123
+ if used > budget:
124
+ return count
125
+ return len(lines)
126
+
127
+
128
+ def derive_key(subject: str, content: str) -> str:
129
+ """Return the key of a fact with `subject` and `content`.
130
+
131
+ Each character of `subject` outside `[A-Za-z0-9_-]` becomes `-`, and the result keeps
132
+ its first 64 characters. An empty subject becomes `fact`. Then come `-` and the first
133
+ 16 hex characters of the SHA-256 of the normalized content.
134
+ """
135
+ label = _NOT_IN_KEY.sub("-", subject)[:SUBJECT_LENGTH] or "fact"
136
+ digest = hashlib.sha256(normalize(content).encode()).hexdigest()[:HASH_LENGTH]
137
+ return f"{label}-{digest}"
138
+
139
+
140
+ class Memory:
141
+ """The memory contract over one `Store`. Each operation is described in docs/contracts.md.
142
+
143
+ `clock` gives the time that an operation needs and the caller does not pass.
144
+ """
145
+
146
+ def __init__(self, store: Store, *, clock: Clock = utc_now) -> None:
147
+ self._store = store
148
+ self._clock = clock
149
+
150
+ @property
151
+ def store(self) -> Store:
152
+ """The store that this memory reads and writes."""
153
+ return self._store
154
+
155
+ def remember(self, facts: Sequence[Fact], scope: Sequence[str], actor: str = "") -> list[str]:
156
+ """Write each fact under `scope` with a derived key, and return the ids, in order.
157
+
158
+ A fact whose content equals the current revision of its key writes nothing and
159
+ returns the current id. The facts go through one writer, so if one fact is
160
+ refused, none is written.
161
+ """
162
+ namespace = check_namespace(scope)
163
+ if not facts:
164
+ return []
165
+ with self._store.writer(max_rows=len(facts), interval=timedelta.max) as writer:
166
+ for fact in facts:
167
+ writer.put(
168
+ namespace,
169
+ derive_key(fact.subject, fact.content),
170
+ kind=fact.kind,
171
+ content=fact.content,
172
+ subject=fact.subject,
173
+ cues=fact.cues,
174
+ payload=fact.payload,
175
+ tags=fact.tags,
176
+ sources=fact.sources,
177
+ actor=actor,
178
+ observed_at=fact.observed_at,
179
+ valid_from=fact.valid_from,
180
+ valid_until=fact.valid_until,
181
+ forget_after=fact.forget_after,
182
+ forget_reason=fact.forget_reason,
183
+ )
184
+ return writer.flushed
185
+
186
+ def recall(
187
+ self,
188
+ query: str,
189
+ scopes: Sequence[Sequence[str]],
190
+ as_of: datetime | None = None,
191
+ budget: int = DEFAULT_BUDGET,
192
+ k: int = 10,
193
+ episodes: Episodes | None = None,
194
+ ) -> tuple[list[Record], str]:
195
+ """Return the top current records for `query` under `scopes`, and their block.
196
+
197
+ The search returns up to `k` current records of the kinds in RECALLED_KINDS. With
198
+ `as_of`, only the ones valid at `as_of` remain. The block holds one line for each
199
+ record, in order, and stops before the first line that would pass `budget`
200
+ characters. With `episodes`, the block then holds the episodes that fit in what is
201
+ left, after a line `episodes:`: those that the facts of the block cite with
202
+ `"cited"`, or the top `k` episodes for `query` with `"search"`. The list holds the
203
+ records of the block, the facts first.
204
+ """
205
+ check_count("budget", budget)
206
+ if episodes not in (None, "cited", "search"):
207
+ raise ValueError(f"episodes must be None, 'cited', or 'search', got {episodes!r}")
208
+ recalled = Filter(kind=RECALLED_KINDS)
209
+ records = [hit.record for hit in self._store.search(query, scopes, recalled, k=k)]
210
+ if as_of is not None:
211
+ records = [record for record in records if is_valid_at(record, as_of)]
212
+ lines = [render_line(record) for record in records]
213
+ count = fit_lines(lines, budget)
214
+ records, lines = records[:count], lines[:count]
215
+ if episodes is None:
216
+ return records, "\n".join(lines)
217
+ if episodes == "cited":
218
+ found = self._cited(records, _scopes(scopes))
219
+ else:
220
+ kind = Filter(kind="episode")
221
+ found = [hit.record for hit in self._store.search(query, scopes, kind, k=k)]
222
+ if as_of is not None:
223
+ found = [episode for episode in found if is_valid_at(episode, as_of)]
224
+ parts: list[str] = []
225
+ shown: list[Record] = []
226
+ for episode in found:
227
+ more = [*parts, render_episode(episode)]
228
+ if len("\n".join([*lines, EPISODES_LINE, *more])) > budget:
229
+ break
230
+ parts, shown = more, [*shown, episode]
231
+ if not shown:
232
+ return records, "\n".join(lines)
233
+ return [*records, *shown], "\n".join([*lines, EPISODES_LINE, *parts])
234
+
235
+ def _cited(self, records: Sequence[Record], allowed: Sequence[tuple[str, ...]]) -> list[Record]:
236
+ """Return the episodes that the sources of `records` name, in order, without repeats.
237
+
238
+ A source that is not a record id, is outside `allowed`, or names no episode is
239
+ skipped, and so is an episode past its `forget_after`. A superseded revision stays.
240
+ """
241
+ cited: dict[str, Record | None] = {}
242
+ for record in records:
243
+ for source in record.sources:
244
+ if source in cited:
245
+ continue
246
+ cited[source] = None
247
+ try:
248
+ namespace, key = _slot(source)
249
+ except ValueError:
250
+ continue
251
+ if not any(under_prefix(namespace, scope) for scope in allowed):
252
+ continue
253
+ revision = int(source.rpartition("@")[2])
254
+ found = self._store.get(namespace, key, revision)
255
+ if (
256
+ found is not None
257
+ and found.kind == "episode"
258
+ and not is_forgotten(found, self._clock())
259
+ ):
260
+ cited[source] = found
261
+ return [episode for episode in cited.values() if episode is not None]
262
+
263
+ def candidates(self, fact: Fact, scopes: Sequence[Sequence[str]], k: int = 5) -> list[Hit]:
264
+ """Return up to `k` current records near the content of `fact`, closest first.
265
+
266
+ Each hit carries the cosine distance to the content. Nothing is decided or written.
267
+ """
268
+ hits = self._store.search(fact.content, scopes, k=k)
269
+ return sorted(hits, key=lambda hit: hit.distance if hit.distance is not None else 0.0)
270
+
271
+ def supersede(
272
+ self,
273
+ scope: Sequence[str],
274
+ key: str,
275
+ fact: Fact,
276
+ valid_from: datetime | None = None,
277
+ actor: str = "",
278
+ ) -> str:
279
+ """Close the current revision of `key` and write `fact` as its next revision.
280
+
281
+ The new `valid_from` is `valid_from`, else that of `fact`, else the clock's time.
282
+ The old revision gets `valid_until` set to it and `expired_at` set to now. Raises
283
+ ValueError if the key has no current revision, or if the old `valid_from` is later
284
+ than the new one. Returns the id of the current revision after the call.
285
+ """
286
+ if self._store.get(scope, key) is None:
287
+ raise ValueError(f"key {key!r} has no current revision to supersede")
288
+ start = next((t for t in (valid_from, fact.valid_from) if t is not None), self._clock())
289
+ return self._store.put(
290
+ scope,
291
+ key,
292
+ kind=fact.kind,
293
+ content=fact.content,
294
+ subject=fact.subject,
295
+ cues=fact.cues,
296
+ payload=fact.payload,
297
+ tags=fact.tags,
298
+ sources=fact.sources,
299
+ actor=actor,
300
+ observed_at=fact.observed_at,
301
+ valid_from=start,
302
+ valid_until=fact.valid_until,
303
+ forget_after=fact.forget_after,
304
+ forget_reason=fact.forget_reason,
305
+ close_previous=True,
306
+ )
307
+
308
+ def forget(
309
+ self,
310
+ scopes: Sequence[Sequence[str]],
311
+ *,
312
+ ids: Sequence[str] | None = None,
313
+ horizon: datetime | None = None,
314
+ ) -> list[str]:
315
+ """Delete every revision of the named keys, or of the keys due before `horizon`.
316
+
317
+ Pass exactly one of `ids` and `horizon`. Each id names its key. With `horizon`,
318
+ a key is due when its current revision has a `forget_after` before `horizon`.
319
+ The candidates come from `list`, up to FORGET_SCAN records under each scope, so a
320
+ record already past its `forget_after` is left to `Store.sweep`. Every id is
321
+ checked before the first delete. Raises ValueError, and deletes nothing, if an id
322
+ is not a valid record id or is outside `scopes`. Returns the ids of the deleted
323
+ revisions, sorted.
324
+ """
325
+ if (ids is None) == (horizon is None):
326
+ raise ValueError("pass exactly one of ids and horizon")
327
+ allowed = _scopes(scopes)
328
+ slots: dict[tuple[tuple[str, ...], str], None] = {}
329
+ if ids is not None:
330
+ if isinstance(ids, str):
331
+ raise TypeError("ids must be a sequence of ids, not a string")
332
+ for record_id in ids:
333
+ namespace, key = _slot(record_id)
334
+ if not any(under_prefix(namespace, prefix) for prefix in allowed):
335
+ raise ValueError(f"id {record_id!r} is outside the allowed scopes")
336
+ slots[(namespace, key)] = None
337
+ elif horizon is not None:
338
+ for prefix in allowed:
339
+ for record in self._store.list(prefix, limit=FORGET_SCAN):
340
+ if record.forget_after is not None and record.forget_after < horizon:
341
+ slots[(record.namespace, record.key)] = None
342
+ deleted: list[str] = []
343
+ for namespace, key in slots:
344
+ deleted.extend(record.id for record in self._store.history(namespace, key))
345
+ self._store.delete(namespace, key)
346
+ return sorted(deleted)
347
+
348
+ def profile(
349
+ self, subject: str, scopes: Sequence[Sequence[str]], budget: int = DEFAULT_BUDGET
350
+ ) -> tuple[list[Record], str]:
351
+ """Return the current records of `subject` under `scopes`, and their block.
352
+
353
+ It reads only the kinds in RECALLED_KINDS, so it never returns an episode. The
354
+ block groups the records by kind, in the order of `Kind`, newest first in each
355
+ group. A line `<kind>:` starts each group. The block ends with `namespaces:` and
356
+ one line `- <namespace>: <count>` for each namespace under `scopes`. The count is
357
+ the number of current records of those kinds in that namespace, read with `list`
358
+ up to COUNT_LIMIT, and shows a `+` when `list` reached the limit. `list` matches
359
+ the namespace as a prefix, so the records of its sub-namespaces share that window,
360
+ and a `+` count is a lower bound. The namespace lines
361
+ take the budget first, and the record lines fill what is left, by whole lines.
362
+ The list holds the records of the block, in block order.
363
+ """
364
+ check_count("budget", budget)
365
+ allowed = _scopes(scopes)
366
+ found: dict[str, Record] = {}
367
+ for prefix in allowed:
368
+ where = Filter(subject=subject, kind=RECALLED_KINDS)
369
+ for record in self._store.list(prefix, where, limit=PROFILE_LIMIT):
370
+ found[record.id] = record
371
+ body: list[tuple[str, Record | None]] = []
372
+ for kind in KINDS:
373
+ group = sorted((r for r in found.values() if r.kind == kind), key=newest_first)
374
+ if group:
375
+ body.append((f"{kind}:", None))
376
+ body.extend((render_line(record), record) for record in group)
377
+ tail = ["namespaces:", *self._counts(allowed)]
378
+ tail = tail[: fit_lines(tail, budget)]
379
+ if len(tail) < 2:
380
+ tail = []
381
+ left = budget - len("\n".join(tail)) - (1 if tail else 0)
382
+ kept = body[: fit_lines([line for line, _ in body], left)]
383
+ while kept and kept[-1][1] is None:
384
+ kept.pop()
385
+ lines = [line for line, _ in kept] + tail
386
+ return [record for _, record in kept if record is not None], "\n".join(lines)
387
+
388
+ def _counts(self, allowed: Sequence[tuple[str, ...]]) -> list[str]:
389
+ """Return one line `- <namespace>: <count>` for each namespace under `allowed`."""
390
+ namespaces = sorted(
391
+ {ns for prefix in allowed for ns in self._store.list_namespaces(prefix)}
392
+ )
393
+ lines = []
394
+ for namespace in namespaces:
395
+ records = self._store.list(namespace, Filter(kind=RECALLED_KINDS), limit=COUNT_LIMIT)
396
+ count = sum(1 for record in records if record.namespace == namespace)
397
+ more = "+" if len(records) == COUNT_LIMIT else ""
398
+ lines.append(f"- {'/'.join(namespace)}: {count}{more}")
399
+ return lines
400
+
401
+ def capture(
402
+ self,
403
+ text: str,
404
+ scope: Sequence[str],
405
+ extractor: Extractor,
406
+ actor: str = "",
407
+ observed_at: datetime | None = None,
408
+ episode: str | None = None,
409
+ chunk_chars: int | None = None,
410
+ ) -> list[str]:
411
+ """Call `extractor` on `text` and remember the facts that it returns.
412
+
413
+ The extractor gets `text`, `observed_at`, and the records that `recall` returns for
414
+ `text` under `scope`. A fact with no `observed_at` gets `observed_at`. With
415
+ `episode`, a key, `text` is also stored as an episode under that key, written after
416
+ the facts, and each fact gets the id of the episode added to its `sources`. If that
417
+ episode is current with the same content, the extractor is not called and nothing
418
+ is written. With `chunk_chars` as well, `text` is cut at blank lines into chunks of
419
+ at most `chunk_chars` characters, and chunk n is stored as an episode under the key
420
+ `<episode>-<n>`, with n from 1 in four digits. The extractor still reads the whole
421
+ text, and each fact gets the id of every chunk added to its `sources`, in chunk
422
+ order. Only the chunks that changed are written, in one flush after the facts, and
423
+ if none changed, the extractor is not called. Raises ValueError if `episode` or a
424
+ chunk key is not a valid key or holds another kind, if `text` is blank, or if
425
+ `chunk_chars` is given without `episode` or is below 1. Returns the ids that
426
+ `remember` returns.
427
+ """
428
+ if episode is None:
429
+ if chunk_chars is not None:
430
+ raise ValueError("chunk_chars needs episode")
431
+ return self._extract(text, scope, extractor, actor, observed_at, ())
432
+ check_key(episode)
433
+ if not text.strip():
434
+ raise ValueError("text must not be blank when capture stores an episode")
435
+ namespace = check_namespace(scope)
436
+ if chunk_chars is not None:
437
+ return self._chunks(
438
+ namespace, episode, text, extractor, actor, observed_at, chunk_chars
439
+ )
440
+ _, facts = self._episode(namespace, episode, text, extractor, actor, observed_at)
441
+ return facts
442
+
443
+ def load(
444
+ self,
445
+ document: str,
446
+ text: str,
447
+ scope: Sequence[str],
448
+ extractor: Extractor,
449
+ actor: str = "",
450
+ observed_at: datetime | None = None,
451
+ chunk_chars: int = DEFAULT_CHUNK_CHARS,
452
+ ) -> tuple[list[str], list[str]]:
453
+ """Cut the Markdown `text` into chunks, store each as an episode, and extract facts.
454
+
455
+ The chunks are those of docs/contracts.md, with bodies of at most `chunk_chars`
456
+ characters. Chunk n is stored as an episode under the key `<document>-<n>`, with n
457
+ from 1 in four digits. For each chunk, the extractor runs as in `capture`, on the
458
+ chunk with its heading prefix, and each fact gets the id of the episode added to
459
+ its `sources`. The facts of a chunk are written before its episode. A chunk whose
460
+ episode is current with the same content is skipped, and the extractor is not
461
+ called for it. Raises ValueError if `document` is not a valid key. Returns the ids
462
+ of the episodes, in order, and the ids of the facts that the extractor returned.
463
+ """
464
+ check_key(document)
465
+ check_count("chunk_chars", chunk_chars)
466
+ if chunk_chars < 1:
467
+ raise ValueError("chunk_chars must be 1 or more")
468
+ namespace = check_namespace(scope)
469
+ episodes: list[str] = []
470
+ facts: list[str] = []
471
+ for number, chunk in enumerate(chunk_document(text, chunk_chars), start=1):
472
+ key = f"{document}-{number:04d}"
473
+ check_key(key)
474
+ episode, written = self._episode(namespace, key, chunk, extractor, actor, observed_at)
475
+ episodes.append(episode)
476
+ facts.extend(written)
477
+ return episodes, facts
478
+
479
+ def _episode(
480
+ self,
481
+ namespace: tuple[str, ...],
482
+ key: str,
483
+ text: str,
484
+ extractor: Extractor,
485
+ actor: str,
486
+ observed_at: datetime | None,
487
+ ) -> tuple[str, list[str]]:
488
+ """Extract the facts of `text`, then store `text` as the episode under `key`.
489
+
490
+ Each fact gets the id of the episode in its `sources`. If the episode is current
491
+ with the same content, nothing is called or written. Returns the id of the episode
492
+ and the ids of the facts.
493
+ """
494
+ current = self._store.get(namespace, key)
495
+ if current is not None and current.kind != "episode":
496
+ raise ValueError(f"key {key!r} holds kind {current.kind!r}, not an episode")
497
+ if current is not None and is_duplicate(current, text, self._clock()):
498
+ return current.id, []
499
+ episode = record_id(namespace, key, next_revision(current))
500
+ facts = self._extract(text, namespace, extractor, actor, observed_at, (episode,))
501
+ written = self._store.put(
502
+ namespace, key, kind="episode", content=text, actor=actor, observed_at=observed_at
503
+ )
504
+ if written != episode:
505
+ raise RuntimeError(f"episode {key!r} was written as {written!r}, not {episode!r}")
506
+ return written, facts
507
+
508
+ def _chunks(
509
+ self,
510
+ namespace: tuple[str, ...],
511
+ episode: str,
512
+ text: str,
513
+ extractor: Extractor,
514
+ actor: str,
515
+ observed_at: datetime | None,
516
+ chunk_chars: int,
517
+ ) -> list[str]:
518
+ """Extract the facts of `text`, then store its changed chunks as episodes.
519
+
520
+ Each fact gets the id of every chunk in its `sources`. If every chunk is current
521
+ with the same content, nothing is called or written. Returns the ids of the facts.
522
+ """
523
+ check_count("chunk_chars", chunk_chars)
524
+ if chunk_chars < 1:
525
+ raise ValueError("chunk_chars must be 1 or more")
526
+ now = self._clock()
527
+ ids: list[str] = []
528
+ changed: list[tuple[str, str, str]] = []
529
+ for number, chunk in enumerate(chunk_text(text, chunk_chars), start=1):
530
+ key = f"{episode}-{number:04d}"
531
+ check_key(key)
532
+ current = self._store.get(namespace, key)
533
+ if current is not None and current.kind != "episode":
534
+ raise ValueError(f"key {key!r} holds kind {current.kind!r}, not an episode")
535
+ if current is not None and is_duplicate(current, chunk, now):
536
+ ids.append(current.id)
537
+ continue
538
+ ids.append(record_id(namespace, key, next_revision(current)))
539
+ changed.append((key, chunk, ids[-1]))
540
+ if not changed:
541
+ return []
542
+ facts = self._extract(text, namespace, extractor, actor, observed_at, tuple(ids))
543
+ with self._store.writer(max_rows=len(changed), interval=timedelta.max) as writer:
544
+ for key, chunk, _ in changed:
545
+ writer.put(
546
+ namespace,
547
+ key,
548
+ kind="episode",
549
+ content=chunk,
550
+ actor=actor,
551
+ observed_at=observed_at,
552
+ )
553
+ expected = [chunk_id for _, _, chunk_id in changed]
554
+ if writer.flushed != expected:
555
+ raise RuntimeError(f"chunks were written as {writer.flushed!r}, not {expected!r}")
556
+ return facts
557
+
558
+ def _extract(
559
+ self,
560
+ text: str,
561
+ scope: Sequence[str],
562
+ extractor: Extractor,
563
+ actor: str,
564
+ observed_at: datetime | None,
565
+ sources: tuple[str, ...],
566
+ ) -> list[str]:
567
+ """Call `extractor` as `capture` does, add `sources` to each fact, and remember them."""
568
+ current, _ = self.recall(text, [scope])
569
+ facts = [
570
+ replace(
571
+ fact,
572
+ observed_at=observed_at if fact.observed_at is None else fact.observed_at,
573
+ sources=tuple(dict.fromkeys((*fact.sources, *sources))),
574
+ )
575
+ for fact in extractor(text, observed_at, current)
576
+ ]
577
+ return self.remember(facts, scope, actor)
578
+
579
+
580
+ def _scopes(scopes: Sequence[Sequence[str]]) -> list[tuple[str, ...]]:
581
+ """Return each scope in `scopes` as a tuple of labels."""
582
+ if isinstance(scopes, str):
583
+ raise TypeError("scopes must be a sequence of scopes, not a string")
584
+ return [check_prefix(scope) for scope in scopes]
585
+
586
+
587
+ def _slot(record_id: str) -> tuple[tuple[str, ...], str]:
588
+ """Return the namespace and the key that a record id names.
589
+
590
+ Raises ValueError if `record_id` is not `namespace/key@revision` with a valid
591
+ namespace, a valid key, and a revision of digits.
592
+ """
593
+ path, separator, revision = record_id.rpartition("@")
594
+ labels = tuple(path.split("/"))
595
+ if not separator or len(labels) < 2 or not revision.isdigit():
596
+ raise ValueError(f"not a record id: {record_id!r}")
597
+ namespace, key = check_namespace(labels[:-1]), labels[-1]
598
+ check_key(key)
599
+ return namespace, key
600
+
601
+
602
+ def _shown_sources(sources: Sequence[str], namespace: tuple[str, ...]) -> list[str]:
603
+ """Return `sources` as the block shows them, with each run of chunks shown as a range."""
604
+ result: list[str] = []
605
+ run: list[tuple[str, int, str]] = []
606
+
607
+ def close() -> None:
608
+ if len(run) > 1:
609
+ prefix, first, revision = run[0]
610
+ result.append(f"{prefix}-{first:04d}{RANGE_MARK}{run[-1][1]:04d}@{revision}")
611
+ elif run:
612
+ prefix, number, revision = run[0]
613
+ result.append(f"{prefix}-{number:04d}@{revision}")
614
+ run.clear()
615
+
616
+ for source in sources:
617
+ text = _shown_source(source, namespace)
618
+ found = _CHUNK_ID.match(text) if text != source else None
619
+ if found is None:
620
+ close()
621
+ result.append(text)
622
+ continue
623
+ prefix, number, revision = found.group(1), int(found.group(2)), found.group(3)
624
+ if run and (run[-1][0], run[-1][1] + 1, run[-1][2]) != (prefix, number, revision):
625
+ close()
626
+ run.append((prefix, number, revision))
627
+ close()
628
+ return result
629
+
630
+
631
+ def _shown_source(source: str, namespace: tuple[str, ...]) -> str:
632
+ """Return `source` as the block shows it: `<key>@<revision>` for a record in `namespace`."""
633
+ prefix = "/".join(namespace) + "/"
634
+ rest = source.removeprefix(prefix)
635
+ key, separator, revision = rest.rpartition("@")
636
+ if rest == source or not separator or not key or "/" in key or not revision.isdigit():
637
+ return source
638
+ return rest
639
+
640
+
641
+ def _time(value: datetime | None, missing: str) -> str:
642
+ """Return `value` in ISO 8601 in UTC to the second, or `missing` if it is None."""
643
+ if value is None:
644
+ return missing
645
+ return value.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")