memir 0.3.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.
memir/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """Memir — the memoir your coding agent writes for itself.
2
+
3
+ A local-first memory layer for coding agents: deterministic, zero-token writes,
4
+ a first-class *failure* memory (never repeat a mistake), and a token-efficient
5
+ briefing. Semantic recall runs on a local CPU model — no API keys, no cloud.
6
+ """
7
+
8
+ from memir.brain import Memir, Memory
9
+
10
+ __version__ = "0.3.0"
11
+ __all__ = ["Memir", "Memory", "LocalEmbedder", "NLIReasoner", "__version__"]
12
+
13
+
14
+ def __getattr__(name):
15
+ # Lazy import so `import memir` stays light until embeddings are used.
16
+ if name == "LocalEmbedder":
17
+ from memir.embeddings import LocalEmbedder
18
+ return LocalEmbedder
19
+ if name == "NLIReasoner":
20
+ from memir.reasoner import NLIReasoner
21
+ return NLIReasoner
22
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
memir/autocapture.py ADDED
@@ -0,0 +1,398 @@
1
+ """
2
+ Auto-capture: turn raw errors (Python tracebacks, pytest output, failed shell
3
+ commands) into structured FAILURE memories — automatically, with zero tokens.
4
+
5
+ The A/B subagent test proved the load-bearing insight here: a strong model
6
+ already avoids *self-evident* and *textbook* mistakes on its own, so storing
7
+ those is pure noise (bloat). The defensible value of the brain is remembering
8
+ *non-obvious, project-specific* failures that leave no trace in the code and
9
+ that no general model could guess (silent drops, env/version quirks, integration
10
+ gotchas, flaky races, data-loss traps).
11
+
12
+ So this module does two things:
13
+ 1. PARSE an error into (attempt, reason, lesson, signature).
14
+ 2. SCORE how worth-remembering it is (novelty / non-obviousness), and let the
15
+ caller skip the boring ones.
16
+
17
+ Pure standard library. No network, no tokens.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ import traceback as _tb
24
+ from dataclasses import dataclass, field
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Obviousness model
28
+ # ---------------------------------------------------------------------------
29
+ # Exception types an agent fixes the instant it sees the traceback. Remembering
30
+ # them adds no value (the next session would catch them just as fast) and only
31
+ # bloats the store. Default novelty for these is LOW.
32
+ _TEXTBOOK_EXC = {
33
+ "SyntaxError", "IndentationError", "TabError",
34
+ "NameError", "UnboundLocalError",
35
+ "ImportError", "ModuleNotFoundError",
36
+ "AttributeError",
37
+ }
38
+
39
+ # Pure-syntax errors can NEVER carry a meaningful runtime signal — their message
40
+ # just echoes source code — so they are always self-evident. Hard-capped.
41
+ _PURE_SYNTAX_EXC = {"SyntaxError", "IndentationError", "TabError"}
42
+
43
+ # STRONG cues: each one alone marks a failure as the expensive, non-obvious kind
44
+ # the brain exists to remember (silent drops, data-loss, integration, races).
45
+ _STRONG_CUES = {
46
+ "silent", "silently", "no error", "empty body", "swallowed",
47
+ "returned 200", "status 200", "no exception", "ok response",
48
+ "timeout", "timed out", "connection", "refused", "reset", "unreachable",
49
+ "dns", "tls", "ssl", "certificate", "deadlock", "race", "concurren",
50
+ "flaky", "intermittent", "corrupt", "data loss", "overwrite", "reconcile",
51
+ "inconsistent", "leak", "injection", "overflow", "truncat", "off-by",
52
+ "off by", "dst", "throttl", "quota", "deprecated", "mismatch", "gateway",
53
+ "502", "503", "504", "security", "rate limit", "precision", "rounding",
54
+ "duplicate",
55
+ }
56
+
57
+ # WEAK cues: suggestive but noisy. One alone is not enough — needs corroboration
58
+ # (another weak cue, a strong cue, or a runtime exception type).
59
+ _WEAK_CUES = {
60
+ "env", "environment", "config", "version", "locale", "encoding",
61
+ "timezone", "utc", "path", "permission", "readonly", "read-only",
62
+ "limit", "proxy", "lock",
63
+ }
64
+
65
+ # Exception types that are inherently runtime/integration in nature => higher base.
66
+ _RUNTIME_EXC = {
67
+ "TimeoutError", "ConnectionError", "ConnectionResetError",
68
+ "ConnectionRefusedError", "BrokenPipeError", "OSError", "IOError",
69
+ "PermissionError", "RuntimeError", "AssertionError",
70
+ "UnicodeDecodeError", "UnicodeEncodeError", "MemoryError",
71
+ "RecursionError",
72
+ }
73
+
74
+ _BUILTIN_TYPES = {
75
+ "str", "bytes", "bytearray", "int", "float", "bool", "complex", "list",
76
+ "dict", "tuple", "set", "frozenset", "nonetype", "range", "object", "type",
77
+ "function", "module", "generator",
78
+ }
79
+
80
+ _NUMERIC_MISMATCH = re.compile(r"\b\d[\d,_.]*\b.*\b(!=|==|expected|actual|got|vs)\b",
81
+ re.IGNORECASE)
82
+
83
+ # canonical textbook message forms — the fingerprints of a self-evident mistake
84
+ _RE_NAMEERR = re.compile(r"name '[^']+' is not defined", re.IGNORECASE)
85
+ _RE_UNBOUND = re.compile(r"referenced before assignment", re.IGNORECASE)
86
+ _RE_NOMODULE = re.compile(r"no module named '[^']+'", re.IGNORECASE)
87
+ _RE_SYNTAX = re.compile(r"invalid syntax|unexpected (eof|indent)|expected ':'",
88
+ re.IGNORECASE)
89
+ _RE_ATTR = re.compile(r"'(?P<obj>[A-Za-z_][\w.]*)' object has no attribute '[\w.]+'")
90
+ # integration / release-skew signals that should rescue a textbook-typed error
91
+ _RE_IMPORT_NAME = re.compile(r"cannot import name '[\w.]+' from '[\w.]+'", re.IGNORECASE)
92
+
93
+
94
+ def _count_cues(blob: str, cues: set[str]) -> int:
95
+ n = 0
96
+ for c in cues:
97
+ if c.replace(" ", "").isalnum(): # plain word(s) -> require boundaries
98
+ if re.search(rf"\b{re.escape(c)}\b", blob):
99
+ n += 1
100
+ elif c in blob: # phrase with punctuation -> substring
101
+ n += 1
102
+ return n
103
+
104
+
105
+ def _domain_lift(exc_type: str, message: str) -> float | None:
106
+ """A textbook-typed exception that is actually a project integration /
107
+ contract-drift bug (worth remembering). Returns a target score, else None.
108
+
109
+ Narrow on purpose: only AttributeError on a *domain* object (CamelCase, not
110
+ a builtin) and ImportError of a specific name from an internal module — the
111
+ cases the A/B red-team showed get silently dropped at 0.15.
112
+ """
113
+ m = _RE_ATTR.search(message)
114
+ if m:
115
+ obj = m.group("obj")
116
+ if obj.lower() not in _BUILTIN_TYPES and obj[:1].isupper():
117
+ return 0.55 # schema / contract drift between collaborating objects
118
+ if _RE_IMPORT_NAME.search(message):
119
+ return 0.55 # release/version skew between internal modules
120
+ return None
121
+
122
+
123
+ @dataclass
124
+ class ParsedError:
125
+ attempt: str # what was being done / the failing thing
126
+ reason: str # the error message / why it failed
127
+ lesson: str = "" # actionable takeaway (may be empty -> caller fills)
128
+ signature: str = "" # stable id for dedup across runs
129
+ exc_type: str = ""
130
+ novelty: float = 0.5 # 0..1, how worth-remembering this is
131
+ source: str = "error" # error | traceback | pytest | command
132
+ tags: list[str] = field(default_factory=list)
133
+
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # Normalisation — strip volatile bits so the same failure dedups across runs
138
+ # ---------------------------------------------------------------------------
139
+ _HEX_ADDR = re.compile(r"0x[0-9a-fA-F]+")
140
+ _TMP_PATH = re.compile(r"[A-Za-z]:\\[^\s'\"]*|/(?:tmp|var)/[^\s'\"]*")
141
+ _LONG_NUM = re.compile(r"\b\d{4,}\b")
142
+ _LINE_NO = re.compile(r"\bline \d+\b", re.IGNORECASE)
143
+ _WS = re.compile(r"\s+")
144
+
145
+
146
+ def _normalize(text: str) -> str:
147
+ t = text or ""
148
+ t = _HEX_ADDR.sub("0xADDR", t)
149
+ t = _TMP_PATH.sub("<path>", t)
150
+ t = _LINE_NO.sub("line N", t)
151
+ t = _LONG_NUM.sub("N", t)
152
+ return _WS.sub(" ", t).strip()
153
+
154
+
155
+ def score_novelty(exc_type: str, message: str) -> float:
156
+ """How worth-remembering is this failure? 0 = textbook/self-evident,
157
+ 1 = expensive, non-obvious, project-specific. The A/B test showed the brain
158
+ only earns its keep on the high end of this scale.
159
+
160
+ Design (hardened after an adversarial red-team of the heuristic):
161
+ * pure syntax errors are always self-evident -> hard floor;
162
+ * cue words match on WORD BOUNDARIES (so "joinpath" no longer trips
163
+ "path", and "timeout=30" in a SyntaxError no longer counts);
164
+ * STRONG cues are decisive; a single WEAK cue only nudges;
165
+ * canonical textbook message forms are capped low UNLESS a domain/
166
+ integration-drift signal rescues them (AttributeError on a domain
167
+ object, ImportError of a name from an internal module).
168
+ """
169
+ message = message or ""
170
+ if exc_type in _PURE_SYNTAX_EXC:
171
+ return 0.1
172
+
173
+ blob = _normalize(f"{exc_type} {message}").lower()
174
+ strong = _count_cues(blob, _STRONG_CUES)
175
+ weak = _count_cues(blob, _WEAK_CUES)
176
+ numeric = bool(_NUMERIC_MISMATCH.search(message))
177
+
178
+ if exc_type in _RUNTIME_EXC:
179
+ base = 0.6
180
+ elif exc_type in _TEXTBOOK_EXC:
181
+ base = 0.15
182
+ else:
183
+ base = 0.5 # unknown / custom / domain-specific exception -> borderline keep
184
+ score = base
185
+
186
+ if strong >= 1:
187
+ score = max(score, 0.7 + min(0.25, 0.08 * (strong - 1)))
188
+ elif weak >= 2:
189
+ score = max(score, 0.6)
190
+ elif weak == 1:
191
+ score = max(score, base + 0.08)
192
+
193
+ if numeric:
194
+ score = max(score, 0.8)
195
+
196
+ # integration / contract-drift can rescue a textbook-typed exception
197
+ lift = _domain_lift(exc_type, message)
198
+ if lift is not None:
199
+ score = max(score, lift)
200
+
201
+ # otherwise, a canonical textbook message with no strong evidence is capped
202
+ canonical = bool(
203
+ _RE_NAMEERR.search(message) or _RE_UNBOUND.search(message)
204
+ or _RE_NOMODULE.search(message) or _RE_SYNTAX.search(message)
205
+ or _RE_ATTR.search(message)
206
+ )
207
+ if canonical and lift is None and strong == 0 and not numeric:
208
+ score = min(score, 0.3)
209
+
210
+ return max(0.0, min(1.0, score))
211
+
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # Parsers
216
+ # ---------------------------------------------------------------------------
217
+ # Cap how much raw text the regex parsers ever scan — a defensive bound against
218
+ # pathological inputs (huge logs / adversarial strings). Error signal lives in
219
+ # the last lines of a traceback anyway.
220
+ _MAX_PARSE_CHARS = 20_000
221
+
222
+
223
+ def _clip(text: str | None) -> str:
224
+ text = text or ""
225
+ if len(text) > _MAX_PARSE_CHARS:
226
+ # keep the tail — the exception line and last frame are at the end
227
+ return text[-_MAX_PARSE_CHARS:]
228
+ return text
229
+
230
+
231
+ def parse_exception(exc: BaseException, attempt: str | None = None) -> ParsedError:
232
+ """Turn a live exception object into a ParsedError."""
233
+ exc_type = type(exc).__name__
234
+ message = str(exc).strip() or exc_type
235
+ # last in-app frame for context
236
+ frame_desc = ""
237
+ tb = exc.__traceback__
238
+ last = None
239
+ for fr in _tb.extract_tb(tb):
240
+ last = fr
241
+ if last is not None:
242
+ fname = last.filename.replace("\\", "/").split("/")[-1]
243
+ frame_desc = f"{fname}:{last.name}()"
244
+ att = attempt or (f"Ran {frame_desc} → {exc_type}" if frame_desc
245
+ else f"Operation raised {exc_type}")
246
+ sig = _normalize(f"{exc_type}: {message}")
247
+ return ParsedError(
248
+ attempt=att,
249
+ reason=f"{exc_type}: {message}",
250
+ signature=sig,
251
+ exc_type=exc_type,
252
+ novelty=score_novelty(exc_type, message),
253
+ source="traceback",
254
+ tags=["auto", "exception", exc_type],
255
+ )
256
+
257
+
258
+ _PY_EXC_LINE = re.compile(
259
+ r"^(?P<type>[A-Za-z_][\w.]*Error|[A-Za-z_][\w.]*Exception|[A-Za-z_]\w*Warning"
260
+ r"|KeyboardInterrupt|StopIteration|SystemExit):?\s*(?P<msg>.*)$"
261
+ )
262
+ _PY_FRAME = re.compile(r'^\s*File "(?P<file>[^"]+)", line (?P<line>\d+), in (?P<fn>.+)$')
263
+
264
+
265
+ def parse_traceback_text(text: str, attempt: str | None = None) -> ParsedError | None:
266
+ """Parse a Python traceback captured as text (e.g. from a subprocess)."""
267
+ text = _clip(text)
268
+ if not text or "Traceback (most recent call last)" not in text:
269
+ # still try: maybe it's a bare 'XxxError: msg' line
270
+ return _parse_bare_error_line(text, attempt)
271
+ lines = [ln.rstrip("\n") for ln in text.splitlines() if ln.strip()]
272
+ # last frame + final exception line
273
+ last_frame = None
274
+ for ln in lines:
275
+ m = _PY_FRAME.match(ln)
276
+ if m:
277
+ last_frame = m
278
+ exc_type, message = "", ""
279
+ for ln in reversed(lines):
280
+ m = _PY_EXC_LINE.match(ln.strip())
281
+ if m:
282
+ exc_type = m.group("type").split(".")[-1]
283
+ message = m.group("msg").strip()
284
+ break
285
+ if not exc_type:
286
+ return _parse_bare_error_line(text, attempt)
287
+ frame_desc = ""
288
+ if last_frame:
289
+ fname = last_frame.group("file").replace("\\", "/").split("/")[-1]
290
+ frame_desc = f"{fname}:{last_frame.group('fn')}()"
291
+ att = attempt or (f"Ran {frame_desc} → {exc_type}" if frame_desc
292
+ else f"Code raised {exc_type}")
293
+ sig = _normalize(f"{exc_type}: {message}")
294
+ return ParsedError(
295
+ attempt=att,
296
+ reason=f"{exc_type}: {message}".strip(),
297
+ signature=sig,
298
+ exc_type=exc_type,
299
+ novelty=score_novelty(exc_type, message),
300
+ source="traceback",
301
+ tags=["auto", "traceback", exc_type],
302
+ )
303
+
304
+
305
+ def _parse_bare_error_line(text: str, attempt: str | None) -> ParsedError | None:
306
+ if not text:
307
+ return None
308
+ for ln in text.splitlines():
309
+ m = _PY_EXC_LINE.match(ln.strip())
310
+ if m:
311
+ exc_type = m.group("type").split(".")[-1]
312
+ message = m.group("msg").strip()
313
+ sig = _normalize(f"{exc_type}: {message}")
314
+ return ParsedError(
315
+ attempt=attempt or f"Code raised {exc_type}",
316
+ reason=f"{exc_type}: {message}".strip(),
317
+ signature=sig,
318
+ exc_type=exc_type,
319
+ novelty=score_novelty(exc_type, message),
320
+ source="error",
321
+ tags=["auto", "error", exc_type],
322
+ )
323
+ return None
324
+
325
+
326
+ _PYTEST_FAIL = re.compile(r"^(?:FAILED|ERROR)\s+(?P<test>[\w./:\[\]-]+)(?:\s+-\s+(?P<msg>.*))?$")
327
+ _PYTEST_ASSERT = re.compile(r"^E\s+(?P<msg>.*)$")
328
+
329
+
330
+ def parse_pytest(text: str) -> list[ParsedError]:
331
+ """Extract one ParsedError per failing test from pytest output."""
332
+ text = _clip(text)
333
+ if not text:
334
+ return []
335
+ out: list[ParsedError] = []
336
+ lines = text.splitlines()
337
+ # 1) the short test summary info lines: "FAILED path::test - AssertionError: ..."
338
+ for ln in lines:
339
+ m = _PYTEST_FAIL.match(ln.strip())
340
+ if not m:
341
+ continue
342
+ test = m.group("test")
343
+ msg = (m.group("msg") or "").strip()
344
+ exc_type = ""
345
+ em = re.match(r"([A-Za-z_][\w.]*(?:Error|Exception)):?\s*(.*)", msg)
346
+ if em:
347
+ exc_type = em.group(1).split(".")[-1]
348
+ msg = em.group(2).strip() or msg
349
+ sig = _normalize(f"pytest {test} {exc_type}: {msg}")
350
+ nov = score_novelty(exc_type, msg)
351
+ # a failing *test* is itself a signal of something non-trivial; floor it up
352
+ nov = max(nov, 0.55)
353
+ out.append(ParsedError(
354
+ attempt=f"Test failed: {test}",
355
+ reason=msg or "test failed",
356
+ signature=sig,
357
+ exc_type=exc_type or "TestFailure",
358
+ novelty=nov,
359
+ source="pytest",
360
+ tags=["auto", "pytest"],
361
+ ))
362
+ return out
363
+
364
+
365
+ def parse_command(stderr: str, returncode: int = 1,
366
+ command: str | None = None) -> ParsedError | None:
367
+ """Parse a failed shell/subprocess command into a ParsedError.
368
+
369
+ Prefers a Python traceback if present; otherwise uses the last meaningful
370
+ stderr line as the reason.
371
+ """
372
+ if returncode == 0:
373
+ return None
374
+ stderr = _clip(stderr)
375
+ tb = parse_traceback_text(stderr or "")
376
+ if tb is not None:
377
+ if command:
378
+ tb.attempt = f"Ran `{command}` (exit {returncode})"
379
+ tb.tags = list(dict.fromkeys([*tb.tags, "command"]))
380
+ return tb
381
+ # fall back to last non-empty stderr line
382
+ last = ""
383
+ for ln in (stderr or "").splitlines():
384
+ if ln.strip():
385
+ last = ln.strip()
386
+ if not last and returncode != 0:
387
+ last = f"command exited with code {returncode}"
388
+ sig = _normalize(f"cmd {command or ''} :: {last}")
389
+ return ParsedError(
390
+ attempt=f"Ran `{command}` (exit {returncode})" if command
391
+ else f"A command failed (exit {returncode})",
392
+ reason=last,
393
+ signature=sig,
394
+ exc_type="CommandError",
395
+ novelty=score_novelty("CommandError", last),
396
+ source="command",
397
+ tags=["auto", "command"],
398
+ )