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 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("&", "&amp;").replace("<", "&lt;")
167
+ .replace(">", "&gt;").replace('"', "&quot;"))
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