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 +22 -0
- memir/autocapture.py +398 -0
- memir/brain.py +1265 -0
- memir/cli.py +297 -0
- memir/embeddings.py +163 -0
- memir/mcp_server.py +419 -0
- memir/optimizer.py +150 -0
- memir/py.typed +1 -0
- memir/reasoner.py +110 -0
- memir/reranker.py +156 -0
- memir-0.3.0.dist-info/METADATA +239 -0
- memir-0.3.0.dist-info/RECORD +16 -0
- memir-0.3.0.dist-info/WHEEL +5 -0
- memir-0.3.0.dist-info/entry_points.txt +3 -0
- memir-0.3.0.dist-info/licenses/LICENSE.md +116 -0
- memir-0.3.0.dist-info/top_level.txt +1 -0
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
|
+
)
|