slimctx 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.
- slimctx/__init__.py +30 -0
- slimctx/code_compressor.py +196 -0
- slimctx/json_compressor.py +234 -0
- slimctx/log_compressor.py +132 -0
- slimctx/mcp_server.py +181 -0
- slimctx/pipeline.py +206 -0
- slimctx/relevance.py +108 -0
- slimctx/router.py +30 -0
- slimctx/store.py +189 -0
- slimctx/text_compressor.py +66 -0
- slimctx/tokens.py +32 -0
- slimctx-0.1.0.dist-info/METADATA +151 -0
- slimctx-0.1.0.dist-info/RECORD +17 -0
- slimctx-0.1.0.dist-info/WHEEL +5 -0
- slimctx-0.1.0.dist-info/entry_points.txt +2 -0
- slimctx-0.1.0.dist-info/licenses/LICENSE +202 -0
- slimctx-0.1.0.dist-info/top_level.txt +1 -0
slimctx/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""slimctx — a zero-dependency, fully-reversible context compression layer
|
|
2
|
+
for AI agents.
|
|
3
|
+
|
|
4
|
+
from slimctx import Pipeline, Config
|
|
5
|
+
|
|
6
|
+
pipe = Pipeline(Config(target_tokens=32_000))
|
|
7
|
+
result = pipe.compress(messages) # OpenAI/Anthropic style dicts
|
|
8
|
+
result.savings_ratio # e.g. 0.72
|
|
9
|
+
pipe.retrieve("a1b2c3...") # byte-exact original back
|
|
10
|
+
|
|
11
|
+
Guarantees:
|
|
12
|
+
* pure Python stdlib — no models, no downloads, no network, ever
|
|
13
|
+
* every lossy transform stores the original first (always reversible)
|
|
14
|
+
* deterministic + memoized -> stable prefixes -> provider caches hit
|
|
15
|
+
* error/warning content is never dropped by any compressor
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from .pipeline import Config, Pipeline, Result
|
|
19
|
+
from .store import MemoryStore, SqliteStore
|
|
20
|
+
from .tokens import estimate_tokens
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Pipeline",
|
|
25
|
+
"Config",
|
|
26
|
+
"Result",
|
|
27
|
+
"MemoryStore",
|
|
28
|
+
"SqliteStore",
|
|
29
|
+
"estimate_tokens",
|
|
30
|
+
]
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Code compression: structural skeletons instead of raw truncation.
|
|
2
|
+
|
|
3
|
+
For Python we use the real AST: keep imports, class/function signatures,
|
|
4
|
+
docstring first-lines, and module-level assignments; elide bodies with a
|
|
5
|
+
line-count note. The model keeps the *map* of the file — usually what it
|
|
6
|
+
actually needs — and can retrieve the full source via the store marker.
|
|
7
|
+
|
|
8
|
+
Query-aware body retention: when the caller passes the user's query, the
|
|
9
|
+
few function bodies most relevant to it are kept verbatim while the rest
|
|
10
|
+
are elided — the model sees the whole map plus the exact code it is being
|
|
11
|
+
asked about. (Blanket skeletonization forces a retrieval round-trip for
|
|
12
|
+
the one function the user actually cares about.)
|
|
13
|
+
|
|
14
|
+
For other languages we fall back to a brace/keyword skeleton: keep lines
|
|
15
|
+
that open declarations, drop deep indentation blocks with elision notes.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import ast
|
|
21
|
+
import re
|
|
22
|
+
from typing import List, Optional
|
|
23
|
+
|
|
24
|
+
from .relevance import Bm25, _stem, tokenize
|
|
25
|
+
|
|
26
|
+
MAX_VERBATIM_BODIES = 3
|
|
27
|
+
DUPLICATE_JACCARD = 0.8
|
|
28
|
+
|
|
29
|
+
_PY_HINTS = re.compile(r"^\s*(def |class |import |from |@|if __name__)", re.MULTILINE)
|
|
30
|
+
_DECL_RE = re.compile(
|
|
31
|
+
r"^\s*(?:export\s+)?(?:public|private|protected|static|async|final|abstract)?\s*"
|
|
32
|
+
r"(?:function|class|interface|struct|enum|impl|fn|func|def|type|const|var|let|"
|
|
33
|
+
r"void|int|string|bool|public|private)\b"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def looks_like_code(text: str) -> bool:
|
|
38
|
+
lines = text.splitlines()
|
|
39
|
+
if len(lines) < 8:
|
|
40
|
+
return False
|
|
41
|
+
if len(_PY_HINTS.findall(text)) >= 3:
|
|
42
|
+
return True
|
|
43
|
+
decl_hits = sum(1 for ln in lines[:80] if _DECL_RE.match(ln))
|
|
44
|
+
brace_lines = sum(1 for ln in lines[:80] if ln.rstrip().endswith(("{", "}", ");")))
|
|
45
|
+
return decl_hits + brace_lines >= len(lines[:80]) * 0.25
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def compress_code(text: str, query: str = "") -> Optional[str]:
|
|
49
|
+
result = _python_skeleton(text, query)
|
|
50
|
+
if result is not None:
|
|
51
|
+
return result
|
|
52
|
+
return _generic_skeleton(text)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _relevant_function_ids(tree: ast.AST, lines: List[str], query: str) -> set:
|
|
56
|
+
"""ids of function nodes whose bodies should be kept verbatim."""
|
|
57
|
+
if not query:
|
|
58
|
+
return set()
|
|
59
|
+
funcs = [
|
|
60
|
+
n for n in ast.walk(tree)
|
|
61
|
+
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
62
|
+
]
|
|
63
|
+
if not funcs:
|
|
64
|
+
return set()
|
|
65
|
+
sources = [
|
|
66
|
+
"\n".join(lines[f.lineno - 1 : getattr(f, "end_lineno", f.lineno)])
|
|
67
|
+
for f in funcs
|
|
68
|
+
]
|
|
69
|
+
bm25 = Bm25(sources)
|
|
70
|
+
scored = sorted(
|
|
71
|
+
((bm25.score(i, query), i) for i in range(len(funcs))), reverse=True
|
|
72
|
+
)
|
|
73
|
+
# Relative gate: an absolute score threshold fails when the query term
|
|
74
|
+
# appears in every function (IDF -> 0). What matters is which bodies
|
|
75
|
+
# score *best*, as long as there is any signal at all. Diversity gate:
|
|
76
|
+
# a body near-identical to one already kept adds no information
|
|
77
|
+
# (common in codebases with many boilerplate-similar functions).
|
|
78
|
+
best = scored[0][0]
|
|
79
|
+
if best <= 1e-9:
|
|
80
|
+
return set()
|
|
81
|
+
keep: set = set()
|
|
82
|
+
kept_tokens: List[set] = []
|
|
83
|
+
for score, i in scored:
|
|
84
|
+
if len(keep) >= MAX_VERBATIM_BODIES or score < best * 0.5:
|
|
85
|
+
break
|
|
86
|
+
toks = {_stem(t) for t in tokenize(sources[i])}
|
|
87
|
+
if any(_jaccard(toks, prev) > DUPLICATE_JACCARD for prev in kept_tokens):
|
|
88
|
+
continue
|
|
89
|
+
keep.add(id(funcs[i]))
|
|
90
|
+
kept_tokens.append(toks)
|
|
91
|
+
return keep
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _jaccard(a: set, b: set) -> float:
|
|
95
|
+
if not a or not b:
|
|
96
|
+
return 0.0
|
|
97
|
+
return len(a & b) / len(a | b)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _python_skeleton(text: str, query: str = "") -> Optional[str]:
|
|
101
|
+
try:
|
|
102
|
+
tree = ast.parse(text)
|
|
103
|
+
except (SyntaxError, ValueError, RecursionError, MemoryError):
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
lines = text.splitlines()
|
|
107
|
+
out: List[str] = []
|
|
108
|
+
verbatim_ids = _relevant_function_ids(tree, lines, query)
|
|
109
|
+
|
|
110
|
+
def emit_docstring(node: ast.AST, indent: str) -> None:
|
|
111
|
+
doc = ast.get_docstring(node, clean=True)
|
|
112
|
+
if doc:
|
|
113
|
+
first = doc.splitlines()[0]
|
|
114
|
+
out.append(f'{indent}"""{first}"""')
|
|
115
|
+
|
|
116
|
+
def signature_lines(node: ast.AST) -> List[str]:
|
|
117
|
+
# decorators + the def/class line(s) up to the colon
|
|
118
|
+
start = min(
|
|
119
|
+
[d.lineno for d in getattr(node, "decorator_list", [])] + [node.lineno]
|
|
120
|
+
)
|
|
121
|
+
body_start = node.body[0].lineno if node.body else node.lineno + 1
|
|
122
|
+
sig = []
|
|
123
|
+
for ln in lines[start - 1 : body_start - 1]:
|
|
124
|
+
sig.append(ln)
|
|
125
|
+
if ln.rstrip().endswith(":"):
|
|
126
|
+
break
|
|
127
|
+
return sig
|
|
128
|
+
|
|
129
|
+
def body_size(node: ast.AST) -> int:
|
|
130
|
+
return (getattr(node, "end_lineno", None) or node.lineno) - node.lineno + 1
|
|
131
|
+
|
|
132
|
+
def visit(node: ast.AST, depth: int) -> None:
|
|
133
|
+
indent = " " * depth
|
|
134
|
+
for child in ast.iter_child_nodes(node):
|
|
135
|
+
if isinstance(child, (ast.Import, ast.ImportFrom)) and depth == 0:
|
|
136
|
+
out.append(lines[child.lineno - 1])
|
|
137
|
+
elif isinstance(child, ast.ClassDef):
|
|
138
|
+
out.extend(signature_lines(child))
|
|
139
|
+
emit_docstring(child, indent + " ")
|
|
140
|
+
visit(child, depth + 1)
|
|
141
|
+
elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
142
|
+
if id(child) in verbatim_ids:
|
|
143
|
+
start = min(
|
|
144
|
+
[d.lineno for d in child.decorator_list] + [child.lineno]
|
|
145
|
+
)
|
|
146
|
+
end = getattr(child, "end_lineno", child.lineno)
|
|
147
|
+
out.extend(lines[start - 1 : end])
|
|
148
|
+
else:
|
|
149
|
+
out.extend(signature_lines(child))
|
|
150
|
+
emit_docstring(child, indent + " ")
|
|
151
|
+
out.append(f"{indent} ... # {body_size(child)} lines")
|
|
152
|
+
elif isinstance(child, (ast.Assign, ast.AnnAssign)) and depth == 0:
|
|
153
|
+
src_line = lines[child.lineno - 1]
|
|
154
|
+
out.append(src_line if len(src_line) < 120 else src_line[:117] + "...")
|
|
155
|
+
|
|
156
|
+
visit(tree, 0)
|
|
157
|
+
if not out:
|
|
158
|
+
return None
|
|
159
|
+
skeleton = "\n".join(out)
|
|
160
|
+
if len(skeleton) >= len(text) * 0.85:
|
|
161
|
+
return None # not worth it
|
|
162
|
+
total = len(lines)
|
|
163
|
+
return f"# skeleton of {total}-line python file (bodies elided)\n{skeleton}"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _generic_skeleton(text: str) -> Optional[str]:
|
|
167
|
+
lines = text.splitlines()
|
|
168
|
+
out: List[str] = []
|
|
169
|
+
elided = 0
|
|
170
|
+
|
|
171
|
+
def flush() -> None:
|
|
172
|
+
nonlocal elided
|
|
173
|
+
if elided:
|
|
174
|
+
out.append(f" ... // {elided} lines elided")
|
|
175
|
+
elided = 0
|
|
176
|
+
|
|
177
|
+
for ln in lines:
|
|
178
|
+
stripped = ln.strip()
|
|
179
|
+
indent = len(ln) - len(ln.lstrip())
|
|
180
|
+
is_structural = (
|
|
181
|
+
_DECL_RE.match(ln)
|
|
182
|
+
or stripped.startswith(("//", "/*", "*", "#", "import ", "package ", "using "))
|
|
183
|
+
or (indent == 0 and stripped in ("}", "};"))
|
|
184
|
+
)
|
|
185
|
+
if is_structural:
|
|
186
|
+
flush()
|
|
187
|
+
out.append(ln)
|
|
188
|
+
elif indent <= 2 and stripped.endswith("{"):
|
|
189
|
+
flush()
|
|
190
|
+
out.append(ln)
|
|
191
|
+
else:
|
|
192
|
+
elided += 1
|
|
193
|
+
flush()
|
|
194
|
+
if not out or len("\n".join(out)) >= len(text) * 0.85:
|
|
195
|
+
return None
|
|
196
|
+
return f"// skeleton of {len(lines)}-line file (bodies elided)\n" + "\n".join(out)
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""JSON compression: lossless tabularization first, relevance-ranked
|
|
2
|
+
row selection second.
|
|
3
|
+
|
|
4
|
+
Stage 1 (lossless): a homogeneous array of objects repeats every key name
|
|
5
|
+
in every element. Re-emitting it as a header + rows table removes that
|
|
6
|
+
redundancy without dropping a single value:
|
|
7
|
+
|
|
8
|
+
[{"id": 1, "name": "a", "ok": true}, ...] ->
|
|
9
|
+
#table cols=id|name|ok rows=200
|
|
10
|
+
1|a|true
|
|
11
|
+
...
|
|
12
|
+
|
|
13
|
+
Nested objects are flattened to dotted columns (meta.region) when uniform.
|
|
14
|
+
|
|
15
|
+
Stage 2 (lossy, only if a row budget is given and exceeded): rank rows and
|
|
16
|
+
keep the best under budget. A row is kept when it (a) contains an error /
|
|
17
|
+
salient value, (b) is a statistical outlier on any numeric column (>2
|
|
18
|
+
sigma), (c) is in the head or tail (positional anchors), or (d) scores
|
|
19
|
+
high BM25 relevance against the query. Kept rows keep original order.
|
|
20
|
+
|
|
21
|
+
Everything else (heterogeneous arrays, scalars, deep trees) is minified
|
|
22
|
+
(no whitespace) — also lossless.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import math
|
|
29
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
30
|
+
|
|
31
|
+
from .relevance import Bm25, salience_score
|
|
32
|
+
|
|
33
|
+
CORE_FIELD_THRESHOLD = 0.8 # key must appear in >=80% of rows to be a column
|
|
34
|
+
HEAD_KEEP = 3
|
|
35
|
+
TAIL_KEEP = 2
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def try_parse(text: str) -> Optional[Any]:
|
|
39
|
+
stripped = text.strip()
|
|
40
|
+
if not stripped or stripped[0] not in "[{":
|
|
41
|
+
return None
|
|
42
|
+
try:
|
|
43
|
+
return json.loads(stripped)
|
|
44
|
+
except (json.JSONDecodeError, ValueError, RecursionError):
|
|
45
|
+
# RecursionError: pathologically nested input (e.g. 100k open
|
|
46
|
+
# brackets) must degrade to "not JSON", not crash the pipeline.
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _flatten(obj: Dict[str, Any], prefix: str = "", max_depth: int = 2) -> Dict[str, Any]:
|
|
51
|
+
flat: Dict[str, Any] = {}
|
|
52
|
+
for k, v in obj.items():
|
|
53
|
+
key = f"{prefix}{k}"
|
|
54
|
+
if isinstance(v, dict) and max_depth > 0 and 0 < len(v) <= 6:
|
|
55
|
+
flat.update(_flatten(v, f"{key}.", max_depth - 1))
|
|
56
|
+
else:
|
|
57
|
+
flat[key] = v
|
|
58
|
+
return flat
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _find_object_array(data: Any) -> Optional[Tuple[List[Dict[str, Any]], Optional[str]]]:
|
|
62
|
+
"""Return (array, wrapper_key) if data is/contains an array of objects."""
|
|
63
|
+
if isinstance(data, list):
|
|
64
|
+
if len(data) >= 2 and all(isinstance(x, dict) for x in data):
|
|
65
|
+
return data, None
|
|
66
|
+
return None
|
|
67
|
+
if isinstance(data, dict):
|
|
68
|
+
# common API shapes: {"items": [...]}, {"results": [...]}, etc.
|
|
69
|
+
candidates = [
|
|
70
|
+
(k, v) for k, v in data.items()
|
|
71
|
+
if isinstance(v, list) and len(v) >= 2 and all(isinstance(x, dict) for x in v)
|
|
72
|
+
]
|
|
73
|
+
if len(candidates) == 1:
|
|
74
|
+
k, v = candidates[0]
|
|
75
|
+
return v, k
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _cell(value: Any) -> str:
|
|
80
|
+
if value is None:
|
|
81
|
+
return ""
|
|
82
|
+
if isinstance(value, bool):
|
|
83
|
+
return "true" if value else "false"
|
|
84
|
+
if isinstance(value, (int, float)):
|
|
85
|
+
return json.dumps(value)
|
|
86
|
+
if isinstance(value, str):
|
|
87
|
+
return value.replace("|", "\\|").replace("\n", "\\n")
|
|
88
|
+
return json.dumps(value, separators=(",", ":"))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _numeric_outlier_rows(rows: List[Dict[str, Any]], cols: Sequence[str]) -> set:
|
|
92
|
+
"""Indices of rows that are >2 sigma from the mean on any numeric column."""
|
|
93
|
+
outliers: set = set()
|
|
94
|
+
for col in cols:
|
|
95
|
+
values = []
|
|
96
|
+
for i, row in enumerate(rows):
|
|
97
|
+
v = row.get(col)
|
|
98
|
+
if isinstance(v, (int, float)) and not isinstance(v, bool):
|
|
99
|
+
values.append((i, float(v)))
|
|
100
|
+
if len(values) < 8:
|
|
101
|
+
continue
|
|
102
|
+
nums = [v for _, v in values]
|
|
103
|
+
mean = sum(nums) / len(nums)
|
|
104
|
+
var = sum((x - mean) ** 2 for x in nums) / len(nums)
|
|
105
|
+
std = math.sqrt(var)
|
|
106
|
+
if std == 0:
|
|
107
|
+
continue
|
|
108
|
+
for i, v in values:
|
|
109
|
+
if abs(v - mean) > 2 * std:
|
|
110
|
+
outliers.add(i)
|
|
111
|
+
return outliers
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def tabularize(
|
|
115
|
+
text: str,
|
|
116
|
+
query: str = "",
|
|
117
|
+
max_rows: Optional[int] = None,
|
|
118
|
+
) -> Optional[Tuple[str, bool]]:
|
|
119
|
+
"""Compress a JSON string. Returns (compressed, was_lossy) or None if
|
|
120
|
+
the input is not JSON we can improve."""
|
|
121
|
+
data = try_parse(text)
|
|
122
|
+
if data is None:
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
found = _find_object_array(data)
|
|
126
|
+
if found is None:
|
|
127
|
+
minified = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
|
|
128
|
+
if len(minified) < len(text) * 0.9:
|
|
129
|
+
return minified, False
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
rows_raw, wrapper = found
|
|
133
|
+
rows = [_flatten(r) for r in rows_raw]
|
|
134
|
+
|
|
135
|
+
# Determine core columns (present in >= threshold of rows).
|
|
136
|
+
freq: Dict[str, int] = {}
|
|
137
|
+
for row in rows:
|
|
138
|
+
for k in row:
|
|
139
|
+
freq[k] = freq.get(k, 0) + 1
|
|
140
|
+
n = len(rows)
|
|
141
|
+
cols = [k for k, c in freq.items() if c >= n * CORE_FIELD_THRESHOLD]
|
|
142
|
+
if len(cols) < 2:
|
|
143
|
+
return None # too heterogeneous to tabularize profitably
|
|
144
|
+
# Stable column order: order of first appearance.
|
|
145
|
+
seen_order: List[str] = []
|
|
146
|
+
for row in rows:
|
|
147
|
+
for k in row:
|
|
148
|
+
if k in cols and k not in seen_order:
|
|
149
|
+
seen_order.append(k)
|
|
150
|
+
cols = seen_order
|
|
151
|
+
|
|
152
|
+
# Constant-column extraction (lossless): a column whose value is
|
|
153
|
+
# identical in every row is stated once in a legend, not repeated
|
|
154
|
+
# per row. Common in API payloads (repo, branch, region, type...).
|
|
155
|
+
consts: Dict[str, Any] = {}
|
|
156
|
+
for col in list(cols):
|
|
157
|
+
if freq.get(col) == n:
|
|
158
|
+
first = rows[0].get(col)
|
|
159
|
+
if all(row.get(col) == first for row in rows):
|
|
160
|
+
consts[col] = first
|
|
161
|
+
cols.remove(col)
|
|
162
|
+
if len(cols) < 1:
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
kept_indices = list(range(n))
|
|
166
|
+
was_lossy = False
|
|
167
|
+
if max_rows is not None and n > max_rows:
|
|
168
|
+
kept_indices = _select_rows(rows, cols, query, max_rows)
|
|
169
|
+
was_lossy = True
|
|
170
|
+
|
|
171
|
+
lines = []
|
|
172
|
+
label = f" key={wrapper}" if wrapper else ""
|
|
173
|
+
shown = len(kept_indices)
|
|
174
|
+
row_note = f"rows={n}" if not was_lossy else f"rows={shown}of{n}"
|
|
175
|
+
lines.append(f"#table{label} cols={'|'.join(cols)} {row_note}")
|
|
176
|
+
if consts:
|
|
177
|
+
legend = " | ".join(f"{k}={_cell(v)}" for k, v in consts.items())
|
|
178
|
+
lines.append(f"#every-row: {legend}")
|
|
179
|
+
prev = -1
|
|
180
|
+
for i in kept_indices:
|
|
181
|
+
if was_lossy and prev >= 0 and i != prev + 1:
|
|
182
|
+
lines.append(f"…({i - prev - 1} rows omitted)…")
|
|
183
|
+
row = rows[i]
|
|
184
|
+
cells = [_cell(row.get(c)) for c in cols]
|
|
185
|
+
extras = {k: v for k, v in row.items() if k not in cols and k not in consts}
|
|
186
|
+
line = "|".join(cells)
|
|
187
|
+
if extras:
|
|
188
|
+
line += " +" + json.dumps(extras, separators=(",", ":"), ensure_ascii=False)
|
|
189
|
+
lines.append(line)
|
|
190
|
+
prev = i
|
|
191
|
+
return "\n".join(lines), was_lossy
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _select_rows(
|
|
195
|
+
rows: List[Dict[str, Any]], cols: Sequence[str], query: str, max_rows: int
|
|
196
|
+
) -> List[int]:
|
|
197
|
+
n = len(rows)
|
|
198
|
+
keep: set = set(range(min(HEAD_KEEP, n))) | set(range(max(0, n - TAIL_KEEP), n))
|
|
199
|
+
|
|
200
|
+
row_texts = [json.dumps(r, ensure_ascii=False) for r in rows]
|
|
201
|
+
# Salience pins a row only when it is *distinctive*: a keyword that
|
|
202
|
+
# appears in most rows (e.g. `timeout=30` in every signature) carries
|
|
203
|
+
# no information about which rows matter.
|
|
204
|
+
salient = [i for i, txt in enumerate(row_texts) if salience_score(txt) >= 0.75]
|
|
205
|
+
if len(salient) <= max(3, n // 5):
|
|
206
|
+
keep.update(salient)
|
|
207
|
+
keep |= _numeric_outlier_rows(rows, cols)
|
|
208
|
+
|
|
209
|
+
if query:
|
|
210
|
+
bm25 = Bm25(row_texts)
|
|
211
|
+
scored = sorted(
|
|
212
|
+
((bm25.score(i, query), i) for i in range(n) if i not in keep),
|
|
213
|
+
reverse=True,
|
|
214
|
+
)
|
|
215
|
+
for score, i in scored:
|
|
216
|
+
if score <= 0.15:
|
|
217
|
+
break
|
|
218
|
+
# Strong matches always earn a slot, even past the row budget —
|
|
219
|
+
# the row that answers the query is the last thing to sacrifice.
|
|
220
|
+
if score >= 0.5 or len(keep) < max_rows:
|
|
221
|
+
keep.add(i)
|
|
222
|
+
elif len(keep) >= max_rows:
|
|
223
|
+
break
|
|
224
|
+
|
|
225
|
+
# Fill remaining budget with evenly spaced samples for coverage.
|
|
226
|
+
if len(keep) < max_rows:
|
|
227
|
+
remaining = [i for i in range(n) if i not in keep]
|
|
228
|
+
want = max_rows - len(keep)
|
|
229
|
+
if remaining and want > 0:
|
|
230
|
+
step = max(1, len(remaining) // want)
|
|
231
|
+
for i in remaining[::step][:want]:
|
|
232
|
+
keep.add(i)
|
|
233
|
+
|
|
234
|
+
return sorted(keep)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Log compression via template mining (Drain-lite).
|
|
2
|
+
|
|
3
|
+
Agent tool output is dominated by *repeated* log lines that differ only in
|
|
4
|
+
volatile fields (timestamps, IDs, durations). Generic text compressors
|
|
5
|
+
score and drop individual sentences; they can't exploit this structure.
|
|
6
|
+
We can:
|
|
7
|
+
|
|
8
|
+
2026-07-19T10:00:01Z GET /api/users/8231 200 12ms
|
|
9
|
+
2026-07-19T10:00:02Z GET /api/users/9917 200 9ms
|
|
10
|
+
... x 1430 more ...
|
|
11
|
+
|
|
12
|
+
becomes one line:
|
|
13
|
+
|
|
14
|
+
<TS> GET /api/users/<NUM> 200 <NUM>ms [x1432]
|
|
15
|
+
|
|
16
|
+
Algorithm:
|
|
17
|
+
1. Mask volatile tokens per line (timestamps, UUIDs, hex, numbers, IPs).
|
|
18
|
+
2. Group lines by masked template.
|
|
19
|
+
3. Emit each template once with its count and 1-2 verbatim examples.
|
|
20
|
+
4. High-salience lines (errors, warnings, tracebacks) are NEVER collapsed —
|
|
21
|
+
they pass through verbatim, in original order, because they are the
|
|
22
|
+
signal the model is being asked to find.
|
|
23
|
+
|
|
24
|
+
Lossy — so the original is always placed in the store first.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import re
|
|
30
|
+
from collections import OrderedDict
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from typing import List
|
|
33
|
+
|
|
34
|
+
from .relevance import salience_score
|
|
35
|
+
|
|
36
|
+
_VOLATILE = [
|
|
37
|
+
(re.compile(r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?"), "<TS>"),
|
|
38
|
+
(re.compile(r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"), "<UUID>"),
|
|
39
|
+
(re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?\b"), "<IP>"),
|
|
40
|
+
(re.compile(r"\b[0-9a-fA-F]{12,}\b"), "<HEX>"),
|
|
41
|
+
# No trailing \b: numbers fused to units ("12ms", "512Mi") must mask
|
|
42
|
+
# too, or every line becomes its own template.
|
|
43
|
+
(re.compile(r"\d+(?:\.\d+)?"), "<NUM>"),
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
# A template must repeat at least this many times before we collapse it;
|
|
47
|
+
# below that, collapsing costs more tokens (marker overhead) than it saves.
|
|
48
|
+
MIN_REPEAT = 3
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _template_of(line: str) -> str:
|
|
52
|
+
for pattern, token in _VOLATILE:
|
|
53
|
+
line = pattern.sub(token, line)
|
|
54
|
+
return line
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class _Group:
|
|
59
|
+
template: str
|
|
60
|
+
count: int = 0
|
|
61
|
+
first_example: str = ""
|
|
62
|
+
last_example: str = ""
|
|
63
|
+
first_index: int = 0
|
|
64
|
+
salient_lines: List[str] = field(default_factory=list)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def looks_like_log(text: str) -> bool:
|
|
68
|
+
"""Heuristic: many short lines, high proportion with timestamps/levels."""
|
|
69
|
+
lines = [ln for ln in text.splitlines() if ln.strip()]
|
|
70
|
+
if len(lines) < 10:
|
|
71
|
+
return False
|
|
72
|
+
hits = 0
|
|
73
|
+
probe = lines[:50]
|
|
74
|
+
level_re = re.compile(r"\b(INFO|DEBUG|WARN|WARNING|ERROR|FATAL|TRACE)\b")
|
|
75
|
+
for ln in probe:
|
|
76
|
+
if _VOLATILE[0][0].search(ln) or level_re.search(ln):
|
|
77
|
+
hits += 1
|
|
78
|
+
return hits >= len(probe) * 0.4
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def compress_log(text: str, max_examples_per_template: int = 1) -> str:
|
|
82
|
+
"""Collapse repeated log templates; keep salient lines verbatim."""
|
|
83
|
+
lines = text.splitlines()
|
|
84
|
+
groups: "OrderedDict[str, _Group]" = OrderedDict()
|
|
85
|
+
salient: List[tuple] = [] # (index, line) — kept verbatim, in order
|
|
86
|
+
|
|
87
|
+
for i, line in enumerate(lines):
|
|
88
|
+
if not line.strip():
|
|
89
|
+
continue
|
|
90
|
+
if salience_score(line) >= 0.6:
|
|
91
|
+
salient.append((i, line))
|
|
92
|
+
continue
|
|
93
|
+
tpl = _template_of(line)
|
|
94
|
+
grp = groups.get(tpl)
|
|
95
|
+
if grp is None:
|
|
96
|
+
grp = _Group(template=tpl, first_example=line, first_index=i)
|
|
97
|
+
groups[tpl] = grp
|
|
98
|
+
grp.count += 1
|
|
99
|
+
grp.last_example = line
|
|
100
|
+
|
|
101
|
+
out: List[str] = []
|
|
102
|
+
emitted_salient = 0
|
|
103
|
+
|
|
104
|
+
# Interleave: emit groups in first-appearance order, and salient lines
|
|
105
|
+
# at their original positions relative to the groups around them.
|
|
106
|
+
events: List[tuple] = []
|
|
107
|
+
for grp in groups.values():
|
|
108
|
+
events.append((grp.first_index, "group", grp))
|
|
109
|
+
for idx, line in salient:
|
|
110
|
+
events.append((idx, "salient", line))
|
|
111
|
+
events.sort(key=lambda e: e[0])
|
|
112
|
+
|
|
113
|
+
for _, kind, payload in events:
|
|
114
|
+
if kind == "salient":
|
|
115
|
+
out.append(payload)
|
|
116
|
+
emitted_salient += 1
|
|
117
|
+
else:
|
|
118
|
+
grp = payload
|
|
119
|
+
if grp.count < MIN_REPEAT:
|
|
120
|
+
out.append(grp.first_example)
|
|
121
|
+
if grp.count == 2:
|
|
122
|
+
out.append(grp.last_example)
|
|
123
|
+
else:
|
|
124
|
+
out.append(f"{grp.template} [x{grp.count}]")
|
|
125
|
+
if max_examples_per_template >= 1:
|
|
126
|
+
out.append(f" e.g. {grp.first_example}")
|
|
127
|
+
|
|
128
|
+
header = (
|
|
129
|
+
f"[log summary: {len(lines)} lines -> {len(out)} shown; "
|
|
130
|
+
f"{len(groups)} unique patterns; {emitted_salient} error/warn lines kept verbatim]"
|
|
131
|
+
)
|
|
132
|
+
return header + "\n" + "\n".join(out)
|