ctxmem 1.4.3__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.
- ctxmem/__init__.py +8 -0
- ctxmem/bench.py +254 -0
- ctxmem/cli.py +816 -0
- ctxmem/codemap.py +121 -0
- ctxmem/embeddings.py +137 -0
- ctxmem/gitinfo.py +19 -0
- ctxmem/indexer.py +84 -0
- ctxmem/mcp_server.py +175 -0
- ctxmem/retrieval.py +126 -0
- ctxmem/store.py +230 -0
- ctxmem-1.4.3.dist-info/METADATA +772 -0
- ctxmem-1.4.3.dist-info/RECORD +16 -0
- ctxmem-1.4.3.dist-info/WHEEL +5 -0
- ctxmem-1.4.3.dist-info/entry_points.txt +3 -0
- ctxmem-1.4.3.dist-info/licenses/LICENSE +21 -0
- ctxmem-1.4.3.dist-info/top_level.txt +1 -0
ctxmem/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""ctxmem - shareable, git-native project memory for AI coding agents."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("ctxmem")
|
|
7
|
+
except PackageNotFoundError: # not installed (e.g. running from a source tree)
|
|
8
|
+
__version__ = "0.0.0"
|
ctxmem/bench.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Token-efficiency benchmark: compare what you'd feed an LLM *with* ctxmem
|
|
3
|
+
(only the relevant recall snippets) versus *without* it (dumping whole files,
|
|
4
|
+
the whole memory, or the whole repo).
|
|
5
|
+
|
|
6
|
+
Token counting uses tiktoken when installed (accurate for OpenAI-family models),
|
|
7
|
+
otherwise a portable ~chars/4 heuristic.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from . import store
|
|
13
|
+
from .indexer import CODE_EXT, SKIP_DIRS
|
|
14
|
+
|
|
15
|
+
# Substrings / patterns that mark a file as test code. Test files are excluded
|
|
16
|
+
# from the baseline by default: a real agent would rarely dump entire test
|
|
17
|
+
# suites to answer a question, so counting them inflates the "without ctxmem"
|
|
18
|
+
# side and makes the benchmark look better than it honestly is.
|
|
19
|
+
_TEST_DIR_PARTS = ("/tests/", "/test/", "/testing/", "/__tests__/")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_test_file(path):
|
|
23
|
+
"""Heuristic: does this path look like test code rather than app source?"""
|
|
24
|
+
p = "/" + path.replace("\\", "/").lower().strip("/") + "/"
|
|
25
|
+
base = os.path.basename(path.replace("\\", "/")).lower()
|
|
26
|
+
if any(part in p for part in _TEST_DIR_PARTS):
|
|
27
|
+
return True
|
|
28
|
+
if base.startswith("test_") or base.endswith("_test.py"):
|
|
29
|
+
return True
|
|
30
|
+
if base in ("tests.py", "test.py", "conftest.py"):
|
|
31
|
+
return True
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def count_tokens(text):
|
|
36
|
+
"""Return (n_tokens, method_label)."""
|
|
37
|
+
try:
|
|
38
|
+
import tiktoken
|
|
39
|
+
enc = tiktoken.get_encoding("cl100k_base")
|
|
40
|
+
return len(enc.encode(text)), "tiktoken/cl100k_base"
|
|
41
|
+
except Exception:
|
|
42
|
+
return max(1, round(len(text) / 4)), "approx(chars/4)"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def recall_payload(rows):
|
|
46
|
+
"""Reconstruct the context block a recall would inject into the model."""
|
|
47
|
+
parts = []
|
|
48
|
+
for r in rows:
|
|
49
|
+
head = "[{}] {}".format(r.get("type", ""), r.get("title") or "")
|
|
50
|
+
path = r.get("path") or ""
|
|
51
|
+
body = r.get("content") or ""
|
|
52
|
+
block = head
|
|
53
|
+
if path:
|
|
54
|
+
block += "\n@ {}".format(path)
|
|
55
|
+
if body:
|
|
56
|
+
block += "\n{}".format(body)
|
|
57
|
+
parts.append(block)
|
|
58
|
+
return "\n\n".join(parts)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _strip_line(path):
|
|
62
|
+
"""'src/foo.py:42' -> 'src/foo.py' (symbol paths carry a line number)."""
|
|
63
|
+
if ":" in path:
|
|
64
|
+
head, tail = path.rsplit(":", 1)
|
|
65
|
+
if tail.isdigit():
|
|
66
|
+
return head
|
|
67
|
+
return path
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def referenced_files(rows, root, include_tests=False):
|
|
71
|
+
"""Unique existing files referenced by the recall results (full paths).
|
|
72
|
+
|
|
73
|
+
Test files are skipped unless include_tests is True.
|
|
74
|
+
"""
|
|
75
|
+
out = []
|
|
76
|
+
seen = set()
|
|
77
|
+
for r in rows:
|
|
78
|
+
p = _strip_line(r.get("path") or "")
|
|
79
|
+
if not p:
|
|
80
|
+
continue
|
|
81
|
+
if not include_tests and is_test_file(p):
|
|
82
|
+
continue
|
|
83
|
+
full = os.path.join(root, p)
|
|
84
|
+
if full in seen:
|
|
85
|
+
continue
|
|
86
|
+
if os.path.isfile(full):
|
|
87
|
+
seen.add(full)
|
|
88
|
+
out.append(full)
|
|
89
|
+
return out
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _read(path):
|
|
93
|
+
try:
|
|
94
|
+
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
|
95
|
+
return f.read()
|
|
96
|
+
except OSError:
|
|
97
|
+
return ""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def whole_memory_text(root):
|
|
101
|
+
_, jsonl_path, _ = store.memory_paths(root)
|
|
102
|
+
return _read(jsonl_path)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def whole_repo_text(root, include_tests=False):
|
|
106
|
+
chunks = []
|
|
107
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
108
|
+
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
109
|
+
for fn in filenames:
|
|
110
|
+
if os.path.splitext(fn)[1] not in CODE_EXT:
|
|
111
|
+
continue
|
|
112
|
+
full = os.path.join(dirpath, fn)
|
|
113
|
+
if not include_tests and is_test_file(os.path.relpath(full, root)):
|
|
114
|
+
continue
|
|
115
|
+
chunks.append(_read(full))
|
|
116
|
+
chunks.append(whole_memory_text(root))
|
|
117
|
+
return "\n".join(chunks)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def baseline_text(rows, root, kind, include_tests=False):
|
|
121
|
+
"""Text a naive approach would feed for this query."""
|
|
122
|
+
if kind == "memory":
|
|
123
|
+
return whole_memory_text(root), ["<whole memory.jsonl>"]
|
|
124
|
+
if kind == "repo":
|
|
125
|
+
return whole_repo_text(root, include_tests), ["<all indexed code + memory.jsonl>"]
|
|
126
|
+
# kind == "files": full text of the files that hold the answer
|
|
127
|
+
files = referenced_files(rows, root, include_tests)
|
|
128
|
+
if not files:
|
|
129
|
+
# No source files behind the results (pure notes): the naive fallback
|
|
130
|
+
# is to paste the whole memory.
|
|
131
|
+
return whole_memory_text(root), ["<whole memory.jsonl (no source files)>"]
|
|
132
|
+
return "\n".join(_read(f) for f in files), [
|
|
133
|
+
os.path.relpath(f, root) for f in files]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def exploration_steps(rows, root, include_tests=False):
|
|
137
|
+
"""Estimate agent round-trips ("premium requests") with vs without ctxmem.
|
|
138
|
+
|
|
139
|
+
Model (deliberately conservative and easy to defend):
|
|
140
|
+
* Without ctxmem the agent must first orient itself (1 search/grep step)
|
|
141
|
+
and then open each relevant file to read the answer (1 step per file).
|
|
142
|
+
-> steps = 1 + <number of source files it has to open>
|
|
143
|
+
* With ctxmem a single `recall` returns every relevant snippet at once.
|
|
144
|
+
-> steps = 1
|
|
145
|
+
|
|
146
|
+
Returns (steps_without, steps_with, n_files).
|
|
147
|
+
"""
|
|
148
|
+
files = referenced_files(rows, root, include_tests)
|
|
149
|
+
n = len(files)
|
|
150
|
+
steps_without = 1 + n if n else 1
|
|
151
|
+
steps_with = 1
|
|
152
|
+
return steps_without, steps_with, n
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# --------------------------------------------------------------------------
|
|
156
|
+
# Tiny dependency-free SVG bar charts (render inline on GitHub markdown).
|
|
157
|
+
# --------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
_COL_WITHOUT = "#e8663c" # warm orange = the costly "without ctxmem" side
|
|
160
|
+
_COL_WITH = "#1f9e8f" # teal = the lean "with ctxmem" side
|
|
161
|
+
_COL_TEXT = "#222222"
|
|
162
|
+
_COL_GRID = "#dddddd"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _svg_escape(s):
|
|
166
|
+
return (str(s).replace("&", "&").replace("<", "<")
|
|
167
|
+
.replace(">", ">").replace('"', """))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _fmt(n):
|
|
171
|
+
"""Human-friendly integer: 1234567 -> '1.23M', 15087 -> '15.1k'."""
|
|
172
|
+
n = float(n)
|
|
173
|
+
if n >= 1_000_000:
|
|
174
|
+
return "{:.2f}M".format(n / 1_000_000)
|
|
175
|
+
if n >= 1_000:
|
|
176
|
+
return "{:.1f}k".format(n / 1_000)
|
|
177
|
+
return "{:.0f}".format(n)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def svg_grouped_bars(title, subtitle, rows, path,
|
|
181
|
+
label_without="without ctxmem", label_with="with ctxmem"):
|
|
182
|
+
"""Write a grouped horizontal bar chart to `path`.
|
|
183
|
+
|
|
184
|
+
rows: list of (label, without_value, with_value). The last row may be a
|
|
185
|
+
TOTAL; it is rendered in bold automatically if its label is 'TOTAL'.
|
|
186
|
+
"""
|
|
187
|
+
width = 940
|
|
188
|
+
left = 300 # room for query labels
|
|
189
|
+
right = 90 # room for value labels
|
|
190
|
+
top = 74
|
|
191
|
+
row_h = 46 # per query (two bars + gap)
|
|
192
|
+
bar_h = 15
|
|
193
|
+
gap = 4
|
|
194
|
+
plot_w = width - left - right
|
|
195
|
+
height = top + row_h * len(rows) + 54
|
|
196
|
+
|
|
197
|
+
max_val = max([max(w, c) for _, w, c in rows] + [1])
|
|
198
|
+
|
|
199
|
+
def bar_len(v):
|
|
200
|
+
return max(1.0, (v / max_val) * plot_w)
|
|
201
|
+
|
|
202
|
+
out = []
|
|
203
|
+
out.append(
|
|
204
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" '
|
|
205
|
+
'viewBox="0 0 {w} {h}" font-family="Segoe UI, Helvetica, Arial, sans-serif">'
|
|
206
|
+
.format(w=width, h=height))
|
|
207
|
+
out.append('<rect width="{}" height="{}" fill="#ffffff"/>'.format(width, height))
|
|
208
|
+
out.append('<text x="24" y="34" font-size="21" font-weight="700" fill="{}">{}</text>'
|
|
209
|
+
.format(_COL_TEXT, _svg_escape(title)))
|
|
210
|
+
if subtitle:
|
|
211
|
+
out.append('<text x="24" y="56" font-size="13" fill="#666666">{}</text>'
|
|
212
|
+
.format(_svg_escape(subtitle)))
|
|
213
|
+
|
|
214
|
+
# legend
|
|
215
|
+
lx = width - right - 250
|
|
216
|
+
out.append('<rect x="{}" y="20" width="13" height="13" fill="{}"/>'.format(lx, _COL_WITHOUT))
|
|
217
|
+
out.append('<text x="{}" y="31" font-size="12" fill="{}">{}</text>'
|
|
218
|
+
.format(lx + 19, _COL_TEXT, _svg_escape(label_without)))
|
|
219
|
+
out.append('<rect x="{}" y="40" width="13" height="13" fill="{}"/>'.format(lx, _COL_WITH))
|
|
220
|
+
out.append('<text x="{}" y="51" font-size="12" fill="{}">{}</text>'
|
|
221
|
+
.format(lx + 19, _COL_TEXT, _svg_escape(label_with)))
|
|
222
|
+
|
|
223
|
+
y = top
|
|
224
|
+
for label, w_val, c_val in rows:
|
|
225
|
+
is_total = str(label).strip().upper() == "TOTAL"
|
|
226
|
+
weight = "700" if is_total else "400"
|
|
227
|
+
if is_total:
|
|
228
|
+
out.append('<line x1="24" y1="{y}" x2="{x2}" y2="{y}" stroke="{c}"/>'
|
|
229
|
+
.format(y=y - 8, x2=width - 24, c=_COL_GRID))
|
|
230
|
+
out.append('<text x="{x}" y="{y}" font-size="13" font-weight="{fw}" '
|
|
231
|
+
'fill="{c}" text-anchor="end">{t}</text>'
|
|
232
|
+
.format(x=left - 12, y=y + bar_h, fw=weight, c=_COL_TEXT,
|
|
233
|
+
t=_svg_escape(label)))
|
|
234
|
+
# without bar
|
|
235
|
+
out.append('<rect x="{x}" y="{y}" width="{wd:.1f}" height="{bh}" rx="2" fill="{c}"/>'
|
|
236
|
+
.format(x=left, y=y, wd=bar_len(w_val), bh=bar_h, c=_COL_WITHOUT))
|
|
237
|
+
out.append('<text x="{x:.1f}" y="{y}" font-size="12" fill="#555">{v}</text>'
|
|
238
|
+
.format(x=left + bar_len(w_val) + 6, y=y + bar_h - 2, v=_fmt(w_val)))
|
|
239
|
+
# with bar
|
|
240
|
+
y2 = y + bar_h + gap
|
|
241
|
+
out.append('<rect x="{x}" y="{y}" width="{wd:.1f}" height="{bh}" rx="2" fill="{c}"/>'
|
|
242
|
+
.format(x=left, y=y2, wd=bar_len(c_val), bh=bar_h, c=_COL_WITH))
|
|
243
|
+
out.append('<text x="{x:.1f}" y="{y}" font-size="12" fill="#555">{v}</text>'
|
|
244
|
+
.format(x=left + bar_len(c_val) + 6, y=y2 + bar_h - 2, v=_fmt(c_val)))
|
|
245
|
+
y += row_h
|
|
246
|
+
|
|
247
|
+
out.append('<text x="24" y="{y}" font-size="11" fill="#999">'
|
|
248
|
+
'Generated by `ctxmem bench` \u2014 lower is better.</text>'
|
|
249
|
+
.format(y=height - 18))
|
|
250
|
+
out.append('</svg>\n')
|
|
251
|
+
svg = "\n".join(out)
|
|
252
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
253
|
+
f.write(svg)
|
|
254
|
+
return path
|