loop-memory 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. loop_memory/__init__.py +62 -0
  2. loop_memory/backends/__init__.py +13 -0
  3. loop_memory/backends/embedding.py +82 -0
  4. loop_memory/backends/sentence_embedder.py +30 -0
  5. loop_memory/backends/vector_store.py +139 -0
  6. loop_memory/cli/__init__.py +0 -0
  7. loop_memory/cli/_common.py +68 -0
  8. loop_memory/cli/commands/__init__.py +13 -0
  9. loop_memory/cli/commands/cognitive.py +205 -0
  10. loop_memory/cli/commands/diag.py +346 -0
  11. loop_memory/cli/commands/graph.py +21 -0
  12. loop_memory/cli/commands/hooks.py +212 -0
  13. loop_memory/cli/commands/read.py +362 -0
  14. loop_memory/cli/commands/serve.py +147 -0
  15. loop_memory/cli/commands/write.py +138 -0
  16. loop_memory/cli/main.py +115 -0
  17. loop_memory/engine/__init__.py +0 -0
  18. loop_memory/engine/loop.py +247 -0
  19. loop_memory/engine/reflect.py +89 -0
  20. loop_memory/examples/__init__.py +0 -0
  21. loop_memory/examples/demo.py +39 -0
  22. loop_memory/export/__init__.py +39 -0
  23. loop_memory/export/memory_md.py +629 -0
  24. loop_memory/graph/__init__.py +0 -0
  25. loop_memory/graph/build.py +259 -0
  26. loop_memory/graph/extract.py +197 -0
  27. loop_memory/ingest/__init__.py +0 -0
  28. loop_memory/ingest/loader.py +782 -0
  29. loop_memory/ingest/pipeline.py +458 -0
  30. loop_memory/jobs/__init__.py +0 -0
  31. loop_memory/jobs/cognitive.py +353 -0
  32. loop_memory/jobs/compact.py +371 -0
  33. loop_memory/jobs/consolidate.py +95 -0
  34. loop_memory/jobs/contradiction.py +281 -0
  35. loop_memory/jobs/evolution.py +2021 -0
  36. loop_memory/jobs/graph.py +395 -0
  37. loop_memory/jobs/llm_compact_pass.py +24 -0
  38. loop_memory/jobs/llm_consolidate.py +980 -0
  39. loop_memory/jobs/scheduler.py +495 -0
  40. loop_memory/llm/__init__.py +0 -0
  41. loop_memory/llm/base.py +80 -0
  42. loop_memory/llm/openai_adapter.py +31 -0
  43. loop_memory/llm/providers.py +517 -0
  44. loop_memory/mcp/__init__.py +804 -0
  45. loop_memory/memory/__init__.py +0 -0
  46. loop_memory/memory/types.py +199 -0
  47. loop_memory/privacy/__init__.py +22 -0
  48. loop_memory/privacy/private.py +46 -0
  49. loop_memory/privacy/redact.py +188 -0
  50. loop_memory/py.typed +0 -0
  51. loop_memory/sdk.py +875 -0
  52. loop_memory/sdk_extensions.py +384 -0
  53. loop_memory/security/__init__.py +20 -0
  54. loop_memory/security/secrets.py +464 -0
  55. loop_memory/serve/__init__.py +0 -0
  56. loop_memory/serve/app.py +506 -0
  57. loop_memory/serve/handlers.py +316 -0
  58. loop_memory/serve/routes/_shared.py +59 -0
  59. loop_memory/serve/routes/admin.py +970 -0
  60. loop_memory/serve/routes/cognitive.py +64 -0
  61. loop_memory/serve/routes/export.py +65 -0
  62. loop_memory/serve/routes/graph.py +101 -0
  63. loop_memory/serve/routes/insights.py +702 -0
  64. loop_memory/serve/routes/memories.py +435 -0
  65. loop_memory/serve/routes/sessions.py +75 -0
  66. loop_memory/serve/routes/system.py +493 -0
  67. loop_memory/serve/routes/wiki.py +812 -0
  68. loop_memory/serve/static/__init__.py +0 -0
  69. loop_memory/serve/static/index.html +15 -0
  70. loop_memory/serve/watcher.py +451 -0
  71. loop_memory/storage/__init__.py +5 -0
  72. loop_memory/storage/retrieval.py +365 -0
  73. loop_memory/storage/sqlite_store.py +3627 -0
  74. loop_memory/wiki/__init__.py +41 -0
  75. loop_memory/wiki/backfill.py +143 -0
  76. loop_memory/wiki/classifier.py +238 -0
  77. loop_memory/wiki/prompts.py +295 -0
  78. loop_memory/wiki/scope.py +227 -0
  79. loop_memory-0.4.0.dist-info/METADATA +627 -0
  80. loop_memory-0.4.0.dist-info/RECORD +84 -0
  81. loop_memory-0.4.0.dist-info/WHEEL +5 -0
  82. loop_memory-0.4.0.dist-info/entry_points.txt +2 -0
  83. loop_memory-0.4.0.dist-info/licenses/LICENSE +21 -0
  84. loop_memory-0.4.0.dist-info/top_level.txt +1 -0
File without changes
@@ -0,0 +1,199 @@
1
+ """Typed memory objects shared across tiers.
2
+
3
+ A `MemoryItem` is the atomic unit of memory. Each tier (short / long /
4
+ episodic / procedural) wraps a collection of items with its own
5
+ retention, retrieval, and update semantics.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ import time
12
+ import uuid
13
+ from collections.abc import Iterable
14
+ from dataclasses import dataclass, field
15
+
16
+
17
+ @dataclass
18
+ class MemoryItem:
19
+ """Atomic memory record.
20
+
21
+ Attributes:
22
+ text: human-readable content.
23
+ embedding: optional numeric vector (set when an embedder is wired in).
24
+ importance: 0..1 score influencing retention priority.
25
+ created_at: unix timestamp.
26
+ ttl: time-to-live in seconds; ``None`` means it never expires.
27
+ kind: 'fact' | 'episode' | 'plan' | 'reflection' | free-form.
28
+ tags: lightweight labels for filtering.
29
+ source: optional pointer back to the originating event.
30
+ """
31
+
32
+ text: str
33
+ embedding: list[float] | None = None
34
+ importance: float = 0.5
35
+ created_at: float = field(default_factory=time.time)
36
+ ttl: float | None = None
37
+ kind: str = "fact"
38
+ tags: list[str] = field(default_factory=list)
39
+ source: str | None = None
40
+ id: str = field(default_factory=lambda: uuid.uuid4().hex)
41
+
42
+ def is_expired(self, now: float | None = None) -> bool:
43
+ if self.ttl is None:
44
+ return False
45
+ cur = now if now is not None else time.time()
46
+ return (cur - self.created_at) > self.ttl
47
+
48
+ def score(self, now: float | None = None) -> float:
49
+ """Recency × importance score in [0, 1]."""
50
+ cur = now if now is not None else time.time()
51
+ age = max(0.0, cur - self.created_at)
52
+ # half-life decay: drop by half every `decay_half_life` seconds
53
+ decay_half_life = 60 * 60 * 24 * 7 # 1 week
54
+ recency = 0.5 ** (age / decay_half_life)
55
+ return max(0.0, min(1.0, self.importance * (0.25 + 0.75 * recency)))
56
+
57
+
58
+ def cosine_similarity(a: list[float], b: list[float]) -> float:
59
+ if not a or not b or len(a) != len(b):
60
+ return 0.0
61
+ dot = sum(x * y for x, y in zip(a, b, strict=False))
62
+ na = math.sqrt(sum(x * x for x in a)) or 1e-12
63
+ nb = math.sqrt(sum(x * x for x in b)) or 1e-12
64
+ return dot / (na * nb)
65
+
66
+
67
+ @dataclass
68
+ class ShortTermMemory:
69
+ """Ephemeral scratchpad — last N turns, FIFO eviction.
70
+
71
+ Mirrors the LLM's working context window. Optional summarization
72
+ is handled by the engine's reflection step.
73
+ """
74
+
75
+ capacity: int = 16
76
+ _items: list[MemoryItem] = field(default_factory=list)
77
+
78
+ def push(self, item: MemoryItem) -> None:
79
+ self._items.append(item)
80
+ if len(self._items) > self.capacity:
81
+ self._items = self._items[-self.capacity :]
82
+
83
+ def extend(self, items: Iterable[MemoryItem]) -> None:
84
+ for it in items:
85
+ self.push(it)
86
+
87
+ def items(self) -> list[MemoryItem]:
88
+ return list(self._items)
89
+
90
+ def clear(self) -> None:
91
+ self._items.clear()
92
+
93
+
94
+ @dataclass
95
+ class LongTermMemory:
96
+ """Persistent, semantic facts.
97
+
98
+ Vector-similarity retrieval when an embedder is provided; otherwise
99
+ falls back to importance-weighted lexical scoring.
100
+ """
101
+
102
+ _items: list[MemoryItem] = field(default_factory=list)
103
+ dedupe_threshold: float = 0.92 # cosine threshold for duplicate suppression
104
+
105
+ def add(self, item: MemoryItem) -> bool:
106
+ """Add an item. Returns False if it was deduplicated."""
107
+ if item.embedding is not None:
108
+ for existing in self._items:
109
+ if existing.embedding is not None:
110
+ sim = cosine_similarity(item.embedding, existing.embedding)
111
+ if sim >= self.dedupe_threshold:
112
+ # boost existing importance instead of duplicating
113
+ existing.importance = max(existing.importance, item.importance)
114
+ existing.created_at = min(existing.created_at, item.created_at)
115
+ return False
116
+ self._items.append(item)
117
+ return True
118
+
119
+ def extend(self, items: Iterable[MemoryItem]) -> list[MemoryItem]:
120
+ added: list[MemoryItem] = []
121
+ for it in items:
122
+ if self.add(it):
123
+ added.append(it)
124
+ return added
125
+
126
+ def search(
127
+ self,
128
+ query_embedding: list[float] | None = None,
129
+ top_k: int = 5,
130
+ now: float | None = None,
131
+ ) -> list[MemoryItem]:
132
+ scored: list[tuple[float, MemoryItem]] = []
133
+ for it in self._items:
134
+ if it.is_expired(now):
135
+ continue
136
+ base = it.score(now)
137
+ if query_embedding is not None and it.embedding is not None:
138
+ base = 0.7 * base + 0.3 * cosine_similarity(query_embedding, it.embedding)
139
+ scored.append((base, it))
140
+ scored.sort(key=lambda x: x[0], reverse=True)
141
+ return [it for _, it in scored[:top_k]]
142
+
143
+ def forget(self, predicate) -> int:
144
+ before = len(self._items)
145
+ self._items = [it for it in self._items if not predicate(it)]
146
+ return before - len(self._items)
147
+
148
+ def gc(self, now: float | None = None) -> int:
149
+ return self.forget(lambda it: it.is_expired(now))
150
+
151
+ def __len__(self) -> int:
152
+ return len(self._items)
153
+
154
+
155
+ @dataclass
156
+ class EpisodicMemory:
157
+ """Time-ordered event stream: what happened, in what order.
158
+
159
+ Used to answer "what did we do recently?" rather than "what do we
160
+ know?". Think of it as a transactional log that the reflection step
161
+ periodically compacts into long-term facts.
162
+ """
163
+
164
+ max_events: int = 1000
165
+ _events: list[MemoryItem] = field(default_factory=list)
166
+
167
+ def record(self, event: MemoryItem) -> None:
168
+ event.kind = event.kind or "episode"
169
+ self._events.append(event)
170
+ if len(self._events) > self.max_events:
171
+ self._events = self._events[-self.max_events :]
172
+
173
+ def recent(self, n: int = 5) -> list[MemoryItem]:
174
+ return list(self._events[-n:])
175
+
176
+ def between(self, t0: float, t1: float) -> list[MemoryItem]:
177
+ return [e for e in self._events if t0 <= e.created_at <= t1]
178
+
179
+
180
+ @dataclass
181
+ class ProceduralMemory:
182
+ """Structured task plan / current-goal stack.
183
+
184
+ Lets the engine track what the user is *trying* to do across turns
185
+ so a single message can be interpreted as the next step of an open
186
+ plan rather than a fresh request.
187
+ """
188
+
189
+ goals: list[MemoryItem] = field(default_factory=list)
190
+
191
+ def push(self, goal: MemoryItem) -> None:
192
+ goal.kind = "plan"
193
+ self.goals.append(goal)
194
+
195
+ def current(self) -> MemoryItem | None:
196
+ return self.goals[-1] if self.goals else None
197
+
198
+ def complete_top(self) -> MemoryItem | None:
199
+ return self.goals.pop() if self.goals else None
@@ -0,0 +1,22 @@
1
+ """Privacy utilities — secret redaction & private-tag handling.
2
+
3
+ Goals:
4
+
5
+ * Never let a recognised secret (API key, JWT, SSH private key, …)
6
+ reach the long-term store. Redact in-place so the surrounding
7
+ context is preserved (e.g. "I set OPENAI_API_KEY=sk-…1234"
8
+ becomes "I set OPENAI_API_KEY=[REDACTED:openai_key]") and the
9
+ LLM still knows roughly what was being talked about.
10
+ * Honour the user-controlled ``<private>...</private>`` tags to
11
+ mark regions that should be skipped entirely.
12
+ * Be deterministic so re-running distillation on the same text
13
+ produces the same redactions (no drift on round-trips).
14
+ """
15
+
16
+ from .redact import redact_text, redact_batch, RedactionSummary, REDACT_KINDS
17
+ from .private import strip_private_spans, has_private_blocks
18
+
19
+ __all__ = [
20
+ "redact_text", "redact_batch", "RedactionSummary", "REDACT_KINDS",
21
+ "strip_private_spans", "has_private_blocks",
22
+ ]
@@ -0,0 +1,46 @@
1
+ """User-controlled ``<private>...</private>`` span handling.
2
+
3
+ Users can mark ranges they want kept out of the long-term store
4
+ entirely (``This is my actual birthday: <private>1990-01-01</private>``)
5
+ or just out of distillation / recall. The span delimiters are
6
+ preserved so the agent still sees the marker itself, but the
7
+ content inside is replaced by ``[PRIVATE:redacted]``.
8
+
9
+ Pattern notes:
10
+
11
+ * Case-insensitive (``<PRIVATE>...</PRIVATE>`` too).
12
+ * Non-greedy so multiple spans per turn all get caught.
13
+ * Multi-line: spans may span newlines.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+
20
+ _PRIVATE_RE = re.compile(
21
+ r"<private>([\s\S]*?)</private>",
22
+ re.IGNORECASE,
23
+ )
24
+
25
+
26
+ def strip_private_spans(text: str, *, replacement: str | None = None) -> str:
27
+ """Replace every ``<private>...</private>`` body with a marker.
28
+
29
+ Default replacement is ``"[PRIVATE:redacted]"``. Pass a different
30
+ ``replacement`` if your pipeline needs something else (e.g. an
31
+ empty string when the entire span is also being dropped).
32
+ """
33
+ if not text:
34
+ return text
35
+ rep = replacement if replacement is not None else "[PRIVATE:redacted]"
36
+ return _PRIVATE_RE.sub(rep, text)
37
+
38
+
39
+ def has_private_blocks(text: str) -> bool:
40
+ """``True`` iff ``text`` contains at least one private span
41
+ (after stripping content). Useful as a cheap pre-filter so
42
+ callers don't run distillation on text that will be empty.
43
+ """
44
+ if not text:
45
+ return False
46
+ return _PRIVATE_RE.search(text) is not None
@@ -0,0 +1,188 @@
1
+ """Pattern-based secret redaction.
2
+
3
+ The redaction layer runs **before** anything that touches long-term
4
+ storage (memory upserts, distilled wiki body, ask/inject output, export
5
+ markdown) so a leaked key never leaves the ingest path.
6
+
7
+ Design choices:
8
+
9
+ * Deterministic — same input always yields the same output. The
10
+ patterns are ordered by length so the longest match wins on
11
+ overlaps (an AWS key is preferred over a generic 32-char
12
+ blob in the same span).
13
+ * Idempotent — re-running redact on already-redacted text is a
14
+ no-op (placeholders are recognised and skipped).
15
+ * Surgeries only the secret — the surrounding context is kept so
16
+ the LLM still sees *what* was being talked about
17
+ (``set OPENAI_API_KEY=sk-…`` becomes
18
+ ``set OPENAI_API_KEY=[REDACTED:openai_key]``).
19
+ * Cheap — pure regex, no model call, microseconds on a 4 KB
20
+ transcript fragment.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import re
26
+ from dataclasses import dataclass, field
27
+ from typing import Iterable
28
+
29
+ # ----- Pattern catalogue ------------------------------------------------
30
+ # Each entry is (kind, compiled regex). Order matters: longer / more
31
+ # specific patterns must come first so a ``sk-…`` token isn't matched
32
+ # as a generic 32-char blob. ``redact_text`` walks the list top-down.
33
+
34
+ REDACT_KINDS: tuple[str, ...] = (
35
+ "private_key_block", # -----BEGIN ... PRIVATE KEY-----
36
+ "openai_key", # sk-<not ant/proj/svc>...
37
+ "openai_project_key", # sk-proj-...
38
+ "openai_service_key", # sk-svc-...
39
+ "anthropic_key", # sk-ant-...
40
+ "gemini_key", # AIza... (Google AI Studio)
41
+ "github_pat", # ghp_ / gho_ / ghs_ / ghu_ / ghr_
42
+ "slack_token", # xoxb- / xoxp- / xoxa- / xoxs-
43
+ "aws_access_key", # AKIA / ASIA
44
+ "jwt", # eyJ...eyJ...{sig}
45
+ "bearer_token", # ``Bearer xxx`` in HTTP headers
46
+ "generic_high_entropy", # fallback: 40+ char opaque blob in
47
+ # an env-style assignment (``KEY=xxx``,
48
+ # ``"api_key": "xxx"``)
49
+ )
50
+
51
+ _PATTERNS: list[tuple[str, re.Pattern[str]]] = [
52
+ # Markers in long-term constants used to bypass subsequent patterns
53
+ # (so re-running on already-redacted text doesn't re-match them).
54
+ ("__redacted_placeholder__", re.compile(r"\[REDACTED:[a-z_]+\]")),
55
+
56
+ # Code-fence-style private keys (capture the whole BEGIN..END block).
57
+ ("private_key_block",
58
+ re.compile(
59
+ r"-----BEGIN (?:RSA |DSA |EC |OPENSSH |PGP |)PRIVATE KEY-----"
60
+ r"[\s\S]*?-----END (?:RSA |DSA |EC |OPENSSH |PGP |)PRIVATE KEY-----",
61
+ re.MULTILINE,
62
+ )),
63
+
64
+ # Order matters: more specific patterns MUST come first so the
65
+ # generic OpenAI matcher doesn't scoop up an Anthropic key.
66
+ ("openai_project_key",
67
+ re.compile(r"\bsk-proj-[A-Za-z0-9_\-]{20,}")),
68
+ ("openai_service_key",
69
+ re.compile(r"\bsk-svc-[A-Za-z0-9_\-]{20,}")),
70
+ ("anthropic_key",
71
+ re.compile(r"\bsk-ant-[A-Za-z0-9_\-]{20,}")),
72
+ # OpenAI user keys: ``sk-...`` where the suffix is NOT a known
73
+ # sub-brand. Excludes ``sk-ant-``, ``sk-proj-``, ``sk-svc-`` so
74
+ # those don't fall through to this generic bucket.
75
+ ("openai_key",
76
+ re.compile(r"\bsk-(?!ant-|proj-|svc-)[A-Za-z0-9_\-]{20,}")),
77
+ ("gemini_key",
78
+ re.compile(r"\bAIza[A-Za-z0-9_\-]{20,}\b")),
79
+ ("github_pat",
80
+ re.compile(r"\b(?:ghp|gho|ghs|ghu|ghr)_[A-Za-z0-9]{25,}\b")),
81
+ ("slack_token",
82
+ re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b")),
83
+ ("aws_access_key",
84
+ re.compile(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b")),
85
+ ("google_api_key",
86
+ re.compile(r"\bAIzaSy[A-Za-z0-9_-]{33}\b")),
87
+
88
+ # JWT: three dot-separated base64url segments, the first being
89
+ # always ``eyJ...``.
90
+ ("jwt",
91
+ re.compile(r"\beyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b")),
92
+
93
+ # Bearer token (used in HTTP headers and tool outputs).
94
+ ("bearer_token",
95
+ re.compile(r"(?i)\bBearer\s+[A-Za-z0-9_\-\.~+\/]{20,}=*")),
96
+ ]
97
+
98
+ # Generic high-entropy fallbacks — only when context suggests a key
99
+ # (env-style assignment or quoted JSON value).
100
+ _GENERIC_ENV = re.compile(
101
+ r"""(?ix)
102
+ (?: ^ | [\s,;{(] )
103
+ (?: (?:[A-Z][A-Z0-9_]{2,}) # ALL_CAPS_KEY_NAME
104
+ | ["']?(?:api[_-]?key|secret|token|password|passwd|access[_-]?key)["']?
105
+ )
106
+ \s*[:=]\s*
107
+ ["']?
108
+ ([A-Za-z0-9_\-\.~+\/]{40,}=*) # long opaque blob
109
+ ["']?
110
+ """,
111
+ )
112
+
113
+
114
+ @dataclass
115
+ class RedactionSummary:
116
+ """Counts of redactions, by kind. Returned by :func:`redact_text`
117
+ so callers (telemetry, UI preview) can show "redacted 3 API
118
+ keys" without re-running the regexes.
119
+ """
120
+ counts: dict[str, int] = field(default_factory=dict)
121
+ total_chars: int = 0 # how many characters were replaced
122
+
123
+ @property
124
+ def total(self) -> int:
125
+ return sum(self.counts.values())
126
+
127
+ def add(self, kind: str, n_chars: int) -> None:
128
+ self.counts[kind] = self.counts.get(kind, 0) + 1
129
+ self.total_chars += n_chars
130
+
131
+ def merge(self, other: RedactionSummary) -> None:
132
+ for k, v in other.counts.items():
133
+ self.counts[k] = self.counts.get(k, 0) + v
134
+ self.total_chars += other.total_chars
135
+
136
+
137
+ def _placeholder(kind: str) -> str:
138
+ return f"[REDACTED:{kind}]"
139
+
140
+
141
+ def redact_text(text: str, *, summary: RedactionSummary | None = None) -> str:
142
+ """Return ``text`` with every recognised secret replaced by a
143
+ short placeholder.
144
+
145
+ The transformation is identity if no pattern matches. A summary
146
+ can be supplied to accumulate counts across many calls.
147
+ """
148
+ if not text:
149
+ return text
150
+ out = text
151
+ for kind, pat in _PATTERNS:
152
+ if kind == "__redacted_placeholder__":
153
+ continue
154
+ # Replace, tracking match length for the summary.
155
+ def _sub(m: re.Match[str], redaction_kind: str = kind) -> str:
156
+ n = len(m.group(0))
157
+ if summary is not None:
158
+ summary.add(redaction_kind, n)
159
+ return _placeholder(redaction_kind)
160
+ out = pat.sub(_sub, out)
161
+
162
+ # Generic fallback: only when an env-style assignment is found.
163
+ def _gen_sub(m: re.Match[str]) -> str:
164
+ opaq = m.group(1)
165
+ if not opaq:
166
+ return m.group(0)
167
+ n = len(opaq)
168
+ if summary is not None:
169
+ summary.add("generic_high_entropy", n)
170
+ # Preserve the leading key name + separator.
171
+ # The capture is the opaque blob; rebuild surrounding context.
172
+ # m.group(0) has full match like 'API_KEY="abcdef..."'.
173
+ prefix = m.group(0).rsplit(opaq, 1)[0]
174
+ return prefix + _placeholder("generic_high_entropy")
175
+ out = _GENERIC_ENV.sub(_gen_sub, out)
176
+
177
+ return out
178
+
179
+
180
+ def redact_batch(texts: Iterable[str]) -> list[str]:
181
+ """Apply :func:`redact_text` to a batch of strings.
182
+
183
+ A shared summary is returned separately if needed
184
+ (``RedactionSummary`` is on ``summary``; this helper only
185
+ returns the redacted texts).
186
+ """
187
+ summary = RedactionSummary()
188
+ return [redact_text(t, summary=summary) for t in texts]
loop_memory/py.typed ADDED
File without changes