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.
- loop_memory/__init__.py +62 -0
- loop_memory/backends/__init__.py +13 -0
- loop_memory/backends/embedding.py +82 -0
- loop_memory/backends/sentence_embedder.py +30 -0
- loop_memory/backends/vector_store.py +139 -0
- loop_memory/cli/__init__.py +0 -0
- loop_memory/cli/_common.py +68 -0
- loop_memory/cli/commands/__init__.py +13 -0
- loop_memory/cli/commands/cognitive.py +205 -0
- loop_memory/cli/commands/diag.py +346 -0
- loop_memory/cli/commands/graph.py +21 -0
- loop_memory/cli/commands/hooks.py +212 -0
- loop_memory/cli/commands/read.py +362 -0
- loop_memory/cli/commands/serve.py +147 -0
- loop_memory/cli/commands/write.py +138 -0
- loop_memory/cli/main.py +115 -0
- loop_memory/engine/__init__.py +0 -0
- loop_memory/engine/loop.py +247 -0
- loop_memory/engine/reflect.py +89 -0
- loop_memory/examples/__init__.py +0 -0
- loop_memory/examples/demo.py +39 -0
- loop_memory/export/__init__.py +39 -0
- loop_memory/export/memory_md.py +629 -0
- loop_memory/graph/__init__.py +0 -0
- loop_memory/graph/build.py +259 -0
- loop_memory/graph/extract.py +197 -0
- loop_memory/ingest/__init__.py +0 -0
- loop_memory/ingest/loader.py +782 -0
- loop_memory/ingest/pipeline.py +458 -0
- loop_memory/jobs/__init__.py +0 -0
- loop_memory/jobs/cognitive.py +353 -0
- loop_memory/jobs/compact.py +371 -0
- loop_memory/jobs/consolidate.py +95 -0
- loop_memory/jobs/contradiction.py +281 -0
- loop_memory/jobs/evolution.py +2021 -0
- loop_memory/jobs/graph.py +395 -0
- loop_memory/jobs/llm_compact_pass.py +24 -0
- loop_memory/jobs/llm_consolidate.py +980 -0
- loop_memory/jobs/scheduler.py +495 -0
- loop_memory/llm/__init__.py +0 -0
- loop_memory/llm/base.py +80 -0
- loop_memory/llm/openai_adapter.py +31 -0
- loop_memory/llm/providers.py +517 -0
- loop_memory/mcp/__init__.py +804 -0
- loop_memory/memory/__init__.py +0 -0
- loop_memory/memory/types.py +199 -0
- loop_memory/privacy/__init__.py +22 -0
- loop_memory/privacy/private.py +46 -0
- loop_memory/privacy/redact.py +188 -0
- loop_memory/py.typed +0 -0
- loop_memory/sdk.py +875 -0
- loop_memory/sdk_extensions.py +384 -0
- loop_memory/security/__init__.py +20 -0
- loop_memory/security/secrets.py +464 -0
- loop_memory/serve/__init__.py +0 -0
- loop_memory/serve/app.py +506 -0
- loop_memory/serve/handlers.py +316 -0
- loop_memory/serve/routes/_shared.py +59 -0
- loop_memory/serve/routes/admin.py +970 -0
- loop_memory/serve/routes/cognitive.py +64 -0
- loop_memory/serve/routes/export.py +65 -0
- loop_memory/serve/routes/graph.py +101 -0
- loop_memory/serve/routes/insights.py +702 -0
- loop_memory/serve/routes/memories.py +435 -0
- loop_memory/serve/routes/sessions.py +75 -0
- loop_memory/serve/routes/system.py +493 -0
- loop_memory/serve/routes/wiki.py +812 -0
- loop_memory/serve/static/__init__.py +0 -0
- loop_memory/serve/static/index.html +15 -0
- loop_memory/serve/watcher.py +451 -0
- loop_memory/storage/__init__.py +5 -0
- loop_memory/storage/retrieval.py +365 -0
- loop_memory/storage/sqlite_store.py +3627 -0
- loop_memory/wiki/__init__.py +41 -0
- loop_memory/wiki/backfill.py +143 -0
- loop_memory/wiki/classifier.py +238 -0
- loop_memory/wiki/prompts.py +295 -0
- loop_memory/wiki/scope.py +227 -0
- loop_memory-0.4.0.dist-info/METADATA +627 -0
- loop_memory-0.4.0.dist-info/RECORD +84 -0
- loop_memory-0.4.0.dist-info/WHEEL +5 -0
- loop_memory-0.4.0.dist-info/entry_points.txt +2 -0
- loop_memory-0.4.0.dist-info/licenses/LICENSE +21 -0
- 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
|