code-recycle 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.
@@ -0,0 +1,3 @@
1
+ """code_recycle: search your own code and verified sources before searching the web."""
2
+
3
+ __version__ = "0.1.0" # kept in step with pyproject.toml
code_recycle/chunks.py ADDED
@@ -0,0 +1,168 @@
1
+ """Split indexed files into searchable pieces ("chunks").
2
+
3
+ - Python: one chunk per function, method and class, with exact line numbers.
4
+ Source: Python docs, ast module (research plan Topic 3).
5
+ - Markdown research entries (URL, where on the page, what it taught, code pattern): one
6
+ structured chunk each. The format is the author's own, so this parser is original applied work.
7
+ - Other Markdown: one chunk per heading section.
8
+ - Anything else, or Python that doesn't parse: fixed windows of lines.
9
+ """
10
+ import ast # Python's own parser, so no third-party install is needed
11
+ import re # finding Markdown headings and research-entry fields
12
+ from dataclasses import dataclass, field # a plain record for one chunk
13
+
14
+ WINDOW = 60 # lines per chunk for plain text
15
+ OVERLAP = 10 # lines shared between neighbouring windows, so a match on a boundary isn't lost
16
+ MAX_SECTION_LINES = 200 # a longer Markdown section is cut into windows
17
+
18
+
19
+ @dataclass
20
+ class Chunk:
21
+ path: str # file path relative to its indexed folder
22
+ kind: str # function, method, class, module, research_entry, section or text
23
+ name: str # function or class name, heading text, or "" for plain text
24
+ start: int # first line, counting from 1
25
+ end: int # last line, inclusive
26
+ text: str # the exact source text
27
+ fields: dict = field(default_factory=dict) # research entries only: url, where, taught, code
28
+
29
+
30
+ def chunk_file(path, text):
31
+ """All chunks for one file. `path` is only used for its extension and for labelling."""
32
+ if path.endswith((".py", ".pyi")):
33
+ return python_chunks(path, text)
34
+ if path.endswith(".md"):
35
+ return markdown_chunks(path, text)
36
+ return text_chunks(path, text)
37
+
38
+
39
+ # ---------- Python ----------
40
+
41
+ def python_chunks(path, text):
42
+ try:
43
+ tree = ast.parse(text) # the whole module as a tree of nodes
44
+ except (SyntaxError, ValueError): # broken or non-UTF-8 source: fall back to plain windows
45
+ return text_chunks(path, text)
46
+ lines = text.splitlines()
47
+ out = []
48
+ doc = ast.get_docstring(tree) # the module's own description, if it has one
49
+ if doc:
50
+ first = tree.body[0] # a docstring is always the first statement
51
+ out.append(Chunk(path, "module", path.rsplit("/", 1)[-1], first.lineno, first.end_lineno,
52
+ _lines(lines, first.lineno, first.end_lineno)))
53
+ _visit(tree, path, lines, out, inside_class=None)
54
+ return out or text_chunks(path, text) # a file of plain statements still gets indexed
55
+
56
+
57
+ def _visit(node, path, lines, out, inside_class):
58
+ for child in ast.iter_child_nodes(node):
59
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
60
+ start = _start_with_decorators(child)
61
+ name = f"{inside_class}.{child.name}" if inside_class else child.name
62
+ kind = "method" if inside_class else "function"
63
+ out.append(Chunk(path, kind, name, start, child.end_lineno, _lines(lines, start, child.end_lineno)))
64
+ # Functions defined inside functions stay part of their parent's chunk
65
+ elif isinstance(child, ast.ClassDef):
66
+ start = _start_with_decorators(child)
67
+ # The class chunk is its header, docstring and method names, not every line again,
68
+ # so a search for the class doesn't just return its biggest method's text twice
69
+ header_end = child.body[0].lineno - 1 if child.body else child.end_lineno
70
+ doc = ast.get_docstring(child) or ""
71
+ methods = [c.name for c in child.body if isinstance(c, (ast.FunctionDef, ast.AsyncFunctionDef))]
72
+ summary = _lines(lines, start, max(start, header_end))
73
+ if doc:
74
+ summary += "\n" + doc
75
+ if methods:
76
+ summary += "\nmethods: " + ", ".join(methods)
77
+ out.append(Chunk(path, "class", child.name, start, child.end_lineno, summary))
78
+ _visit(child, path, lines, out, inside_class=child.name)
79
+
80
+
81
+ def _start_with_decorators(node):
82
+ # lineno is the "def" or "class" line; decorators sit above it and belong to it
83
+ return min([node.lineno] + [d.lineno for d in node.decorator_list])
84
+
85
+
86
+ def _lines(lines, start, end):
87
+ return "\n".join(lines[start - 1:end]) # line numbers count from 1, list indexes from 0
88
+
89
+
90
+ # ---------- Markdown ----------
91
+
92
+ HEADING = re.compile(r"^(#{1,6})\s+(.*)")
93
+ # Bold or plain: "- **URL:** x", "- URL: x", "**What it taught (my reading):** x".
94
+ FIELD = re.compile(r"^\s*(?:[-*]\s+)?(?:\*\*)?(URLs?|Where on the page|What it taught|Code pattern from source)\b[^:*\n]*?:(?:\*\*)?\s?(.*)")
95
+ FIELD_KEYS = {"URL": "url", "URLs": "url", "Where on the page": "where",
96
+ "What it taught": "taught", "Code pattern from source": "code"}
97
+
98
+
99
+ def markdown_chunks(path, text):
100
+ lines = text.splitlines()
101
+ sections = [] # (heading text, first line, last line)
102
+ current_name, current_start, in_fence = "", 1, False
103
+ for i, line in enumerate(lines, start=1):
104
+ if line.lstrip().startswith("```"):
105
+ in_fence = not in_fence # a "#" inside a code block is not a heading
106
+ m = None if in_fence else HEADING.match(line)
107
+ if m:
108
+ if i > current_start:
109
+ sections.append((current_name, current_start, i - 1))
110
+ current_name, current_start = m.group(2).strip(), i
111
+ if lines:
112
+ sections.append((current_name, current_start, len(lines)))
113
+
114
+ out = []
115
+ for name, start, end in sections:
116
+ body = lines[start - 1:end]
117
+ if not any(l.strip() for l in body):
118
+ continue # an empty section has nothing to find
119
+ fields = research_fields(body)
120
+ if fields.get("url"):
121
+ out.append(Chunk(path, "research_entry", name, start, end, "\n".join(body), fields))
122
+ elif end - start + 1 <= MAX_SECTION_LINES:
123
+ out.append(Chunk(path, "section", name, start, end, "\n".join(body)))
124
+ else:
125
+ for c in _windows(path, body, start):
126
+ c.kind, c.name = "section", name
127
+ out.append(c)
128
+ return out
129
+
130
+
131
+ def research_fields(body):
132
+ """The four fields of a research entry, as plain text. Empty dict if the section has no URL field."""
133
+ fields, key, in_fence = {}, None, False
134
+ for line in body:
135
+ if line.lstrip().startswith("```"):
136
+ in_fence = not in_fence # inside a code block, "#" lines are code, not headings
137
+ m = None if in_fence else FIELD.match(line)
138
+ if m:
139
+ key = FIELD_KEYS[m.group(1)]
140
+ fields[key] = m.group(2).strip()
141
+ elif key and (in_fence or not HEADING.match(line)):
142
+ fields[key] = fields[key] + "\n" + line if fields[key] else line # a field runs on until the next label
143
+ out = {k: v.strip() for k, v in fields.items()}
144
+ if out.get("code"):
145
+ out["code"] = strip_fences(out["code"])
146
+ return out
147
+
148
+
149
+ def strip_fences(text):
150
+ """Code blocks without their ``` marker lines, so the code reads as code. Blank lines inside are kept."""
151
+ return "\n".join(l for l in text.splitlines() if not l.lstrip().startswith("```")).strip("\n")
152
+
153
+
154
+ # ---------- Plain text ----------
155
+
156
+ def text_chunks(path, text):
157
+ return _windows(path, text.splitlines(), 1)
158
+
159
+
160
+ def _windows(path, lines, first_line):
161
+ out, step = [], WINDOW - OVERLAP
162
+ for i in range(0, max(len(lines), 1), step):
163
+ part = lines[i:i + WINDOW]
164
+ if any(l.strip() for l in part):
165
+ out.append(Chunk(path, "text", "", first_line + i, first_line + i + len(part) - 1, "\n".join(part)))
166
+ if i + WINDOW >= len(lines):
167
+ break # this window reached the end of the file
168
+ return out
code_recycle/cli.py ADDED
@@ -0,0 +1,69 @@
1
+ """The code-recycle command: init, index, search and serve.
2
+
3
+ Installed as a command by [project.scripts] in pyproject.toml (research plan Topic 12).
4
+ """
5
+ import argparse # reads the command-line options
6
+ import os # the CODE_RECYCLE_SETTINGS environment variable
7
+ import sys # exit codes and error output
8
+ from pathlib import Path # paths that work on every platform
9
+
10
+ from . import __version__, files, index, search, settings as settings_mod
11
+
12
+ DEFAULT_NAME = "code_recycle.toml"
13
+
14
+
15
+ def find_settings(given):
16
+ """Which settings file to use: --settings, then $CODE_RECYCLE_SETTINGS, then ./code_recycle.toml,
17
+ then ~/.config/code_recycle/code_recycle.toml."""
18
+ if given:
19
+ return Path(given)
20
+ if os.environ.get("CODE_RECYCLE_SETTINGS"):
21
+ return Path(os.environ["CODE_RECYCLE_SETTINGS"])
22
+ local = Path.cwd() / DEFAULT_NAME
23
+ if local.exists():
24
+ return local
25
+ return Path.home() / ".config" / "code_recycle" / DEFAULT_NAME
26
+
27
+
28
+ def main(argv=None):
29
+ p = argparse.ArgumentParser(prog="code-recycle", description="Search your own code and verified sources before the web.")
30
+ p.add_argument("--version", action="version", version=f"code-recycle {__version__}")
31
+ p.add_argument("--settings", help="settings file (default: ./code_recycle.toml, then ~/.config/code_recycle/)")
32
+ sub = p.add_subparsers(dest="command", required=True)
33
+ sub.add_parser("init", help="write an example settings file in this folder")
34
+ sub.add_parser("index", help="build or rebuild the index and print the index report")
35
+ q = sub.add_parser("search", help="search from the command line")
36
+ q.add_argument("query", nargs="+", help="what you're looking for, in plain words")
37
+ q.add_argument("--from", dest="from_path", help="the file or folder you're working in, for reuse rules")
38
+ q.add_argument("-n", "--limit", type=int, default=5)
39
+ sub.add_parser("serve", help="run the MCP server over stdio (what an MCP client launches)")
40
+ a = p.parse_args(argv)
41
+
42
+ if a.command == "init":
43
+ target = Path(a.settings or DEFAULT_NAME)
44
+ if target.exists():
45
+ print(f"{target} already exists; not overwriting it.", file=sys.stderr)
46
+ return 1
47
+ target.write_text(settings_mod.EXAMPLE)
48
+ print(f"Wrote {target}. Edit the folders list, then run: code-recycle index")
49
+ return 0
50
+
51
+ path = find_settings(a.settings)
52
+ try:
53
+ s = settings_mod.load(path)
54
+ if a.command == "index":
55
+ walked = index.build(s)
56
+ print(files.report(walked))
57
+ elif a.command == "search":
58
+ print(search.format_answer(search.search(s, " ".join(a.query), from_path=a.from_path, limit=a.limit)))
59
+ elif a.command == "serve":
60
+ from .server import build_server # imported here so the other commands don't need the MCP SDK loaded
61
+ build_server(path).run() # blocks, speaking MCP over stdin and stdout
62
+ except (settings_mod.SettingsError, index.IndexBuildError) as e:
63
+ print(f"code-recycle: {e}", file=sys.stderr)
64
+ return 2
65
+ return 0
66
+
67
+
68
+ if __name__ == "__main__":
69
+ sys.exit(main())
code_recycle/embed.py ADDED
@@ -0,0 +1,68 @@
1
+ """Search by meaning: local embeddings through fastembed, stored in the same SQLite index.
2
+
3
+ Sources: fastembed README, "Quickstart" and "Dense text embeddings"; fastembed's own
4
+ TextEmbedding.query_embed docstring for the query form (research plan Topic 4).
5
+ Vectors live in a plain SQLite table and are compared with numpy, so no vector database is needed
6
+ (Topic 5: about 25,000 pieces of 384 numbers is under 40 MB). Combining with keyword results uses
7
+ Reciprocal Rank Fusion, k = 60 (Cormack et al. 2009, Topic 6).
8
+
9
+ fastembed is an optional install: pip install "code-recycle[embed]". Without it, search is keyword only.
10
+ """
11
+ MAX_CHARS = 400 # each piece is cut to this before embedding (results/prereg_embeddings.md, amendment 1)
12
+ EMBEDDED_KINDS = {"function", "method", "class", "module", "research_entry"} # sections and text windows stay keyword only
13
+ RRF_K = 60 # from the RRF paper; not tuned
14
+
15
+
16
+ class EmbeddingsUnavailable(RuntimeError):
17
+ """Embeddings are switched on in settings but fastembed isn't installed."""
18
+
19
+
20
+ def load_model(name):
21
+ try:
22
+ from fastembed import TextEmbedding # imported here, so keyword-only installs never need it
23
+ except ImportError:
24
+ raise EmbeddingsUnavailable(
25
+ 'Embeddings are on in settings, but fastembed is not installed. Run: pip install "code-recycle[embed]"'
26
+ )
27
+ return TextEmbedding(model_name=name) # downloads the model on first use, then works offline
28
+
29
+
30
+ def piece_text(name, text, taught="", where="", code=""):
31
+ """What gets embedded for one piece (results/prereg_embeddings.md, amendment 1).
32
+
33
+ Code pieces: the name, then the piece's own text. Research entries: the heading, what it
34
+ taught, where on the page, then the copied code. Cut to MAX_CHARS.
35
+ """
36
+ parts = (name, taught, where, code) if (taught or where or code) else (name, text)
37
+ return "\n".join(p for p in parts if p)[:MAX_CHARS]
38
+
39
+
40
+ def embed_pieces(model, texts):
41
+ """Document vectors, unit length, as one float32 numpy array (one row per text)."""
42
+ import numpy as np # installed with fastembed
43
+ v = np.array(list(model.embed(texts)), dtype=np.float32)
44
+ return v / np.maximum(np.linalg.norm(v, axis=1, keepdims=True), 1e-12) # unit length, so a dot product is cosine
45
+
46
+
47
+ def embed_query(model, text):
48
+ import numpy as np
49
+ v = np.array(list(model.query_embed([text]))[0], dtype=np.float32) # the model's query form
50
+ return v / max(float(np.linalg.norm(v)), 1e-12)
51
+
52
+
53
+ def nearest(matrix, rowids, qvec, n):
54
+ """The n rowids whose vectors are closest in meaning to the query, best first."""
55
+ import numpy as np
56
+ scores = matrix @ qvec # cosine similarity, because every vector is unit length
57
+ n = min(n, len(rowids))
58
+ top = np.argpartition(-scores, n - 1)[:n] # the n best, unordered (fast)
59
+ return [rowids[i] for i in top[np.argsort(-scores[top])]] # then ordered
60
+
61
+
62
+ def fuse(*rankings, k=RRF_K):
63
+ """Reciprocal Rank Fusion: each item scores the sum of 1 / (k + rank) over the rankings it appears in."""
64
+ score = {}
65
+ for ranking in rankings:
66
+ for rank, item in enumerate(ranking, start=1):
67
+ score[item] = score.get(item, 0.0) + 1.0 / (k + rank)
68
+ return sorted(score, key=lambda item: -score[item])
code_recycle/files.py ADDED
@@ -0,0 +1,155 @@
1
+ """Walk the indexed folders, deciding which files go in and recording why the rest don't.
2
+
3
+ Skip rules use pathspec's GitIgnoreSpec (research plan Topic 9). The order of checks and the
4
+ index report are original applied work.
5
+ """
6
+ import os # os.walk, so whole skipped folders are never entered
7
+ from dataclasses import dataclass, field # plain records for the walk's results
8
+ from concurrent.futures import ProcessPoolExecutor # spreads the slow secret scan over every CPU core
9
+ from pathlib import Path # path handling that works on every platform
10
+ from pathspec import GitIgnoreSpec # Git's own .gitignore behaviour, edge cases included
11
+
12
+ from . import secret_check as secrets # the secrets policy (Topic 10); not named secrets.py, which is a standard-library module
13
+
14
+ ALWAYS_SKIP = [ # never useful to search
15
+ ".git/", ".hg/", ".svn/", "__pycache__/", "*venv/", "node_modules/", "site-packages/",
16
+ ".pytest_cache/", ".mypy_cache/", ".ruff_cache/", ".tox/", ".code_recycle/",
17
+ "code_recycle.toml", # the settings file itself
18
+ ]
19
+
20
+
21
+ @dataclass
22
+ class Skipped:
23
+ path: str # path relative to its indexed folder
24
+ reason: str # plain-words reason shown in the index report
25
+
26
+
27
+ @dataclass
28
+ class Walk:
29
+ included: list = field(default_factory=list) # (folder, relative path) pairs to index
30
+ skipped: list = field(default_factory=list) # Skipped records for the index report
31
+
32
+
33
+ def _spec(lines):
34
+ return GitIgnoreSpec.from_lines(lines) # compile once, match many times
35
+
36
+
37
+ def _is_binary(path):
38
+ # Original rule: a zero byte in the first 8 KB means it isn't text worth searching
39
+ with open(path, "rb") as f:
40
+ return b"\0" in f.read(8192)
41
+
42
+
43
+ def walk(settings):
44
+ """Every file under each folder, sorted into included and skipped, with a reason for each skip.
45
+
46
+ Two passes: cheap checks first, then the slow secret scan on what's left, spread over every CPU core.
47
+ """
48
+ out = Walk()
49
+ user_skip = _spec(ALWAYS_SKIP + settings.skip) # built-in skips plus the user's own
50
+ allow = _spec(settings.secrets_allow) # files the user checked and allowed despite a scanner flag
51
+ include = _spec(settings.include) # file types worth indexing
52
+ candidates = [] # (folder, relative path, full path, needs a secret scan), in walk order
53
+ for folder in settings.folders:
54
+ gitignore = _spec([]) # empty unless the folder has its own .gitignore
55
+ gi = folder / ".gitignore"
56
+ if settings.use_gitignore and gi.is_file():
57
+ with open(gi, encoding="utf-8", errors="replace") as f:
58
+ gitignore = _spec(f) # a file object works as the list of lines
59
+ for root, dirs, names in os.walk(folder):
60
+ rel_root = Path(root).relative_to(folder).as_posix() # posix slashes, so patterns match on Windows too
61
+ prefix = "" if rel_root == "." else rel_root + "/"
62
+ # Prune skipped folders here so os.walk never goes inside them. Patterns ending in "/"
63
+ # only match folders, so each name is tested with a trailing slash.
64
+ kept = []
65
+ for d in sorted(dirs):
66
+ rel = prefix + d + "/"
67
+ if user_skip.match_file(rel):
68
+ out.skipped.append(Skipped(rel, "skip rule"))
69
+ elif gitignore.match_file(rel):
70
+ out.skipped.append(Skipped(rel, ".gitignore"))
71
+ else:
72
+ kept.append(d)
73
+ dirs[:] = kept # changing the list in place is how os.walk is told not to descend
74
+ for name in sorted(names):
75
+ rel = prefix + name
76
+ full = Path(root) / name
77
+ reason = _cheap_check(full, rel, settings, user_skip, gitignore, include)
78
+ if reason:
79
+ out.skipped.append(Skipped(rel, reason))
80
+ else:
81
+ candidates.append((folder, rel, full, not allow.match_file(rel)))
82
+ to_scan = [full for _, _, full, needs in candidates if needs]
83
+ found = dict(zip(to_scan, _scan_all(to_scan, settings.secrets_strict))) # full path -> detector names
84
+ for folder, rel, full, needs in candidates:
85
+ if needs and found[full]:
86
+ out.skipped.append(Skipped(rel, "possible secret: " + ", ".join(found[full])))
87
+ else:
88
+ out.included.append((folder, rel))
89
+ return out
90
+
91
+
92
+ def _cheap_check(full, rel, settings, user_skip, gitignore, include):
93
+ # Every check that doesn't need the secret scanner, cheapest first. Returns a reason or None.
94
+ if secrets.is_secret_name(rel):
95
+ return "secret file name (never read)"
96
+ if user_skip.match_file(rel):
97
+ return "skip rule"
98
+ if gitignore.match_file(rel):
99
+ return ".gitignore"
100
+ if not include.match_file(rel):
101
+ return "file type not included"
102
+ if full.is_symlink():
103
+ return "symbolic link" # a link could point outside the folder the user chose
104
+ size = full.stat().st_size
105
+ if size > settings.max_file_kb * 1024:
106
+ return f"over {settings.max_file_kb} KB"
107
+ if size == 0:
108
+ return "empty"
109
+ if _is_binary(full):
110
+ return "binary"
111
+ return None
112
+
113
+
114
+ PARALLEL_FROM = 50 # below this many files, starting worker processes costs more than it saves
115
+ _worker_ctx = None # each worker keeps its scanner's with-block open; if this were dropped, the block would close
116
+ _worker_scan = None # and the scanner would silently run with no detectors (caught by a test)
117
+
118
+
119
+ def _init_worker(strict):
120
+ global _worker_ctx, _worker_scan
121
+ _worker_ctx = secrets.file_scanner(strict) # the scanner's settings live inside a with-block
122
+ _worker_scan = _worker_ctx.__enter__() # entered once and kept open for the worker's life
123
+
124
+
125
+ def _scan_in_worker(path):
126
+ return _worker_scan(path)
127
+
128
+
129
+ def _scan_all(paths, strict):
130
+ """Secret-scan every path, in order. Uses every CPU core when there are enough files."""
131
+ if len(paths) < PARALLEL_FROM:
132
+ with secrets.file_scanner(strict) as scan:
133
+ return [scan(p) for p in paths]
134
+ with ProcessPoolExecutor(initializer=_init_worker, initargs=(strict,)) as pool: # one worker per core
135
+ return list(pool.map(_scan_in_worker, paths, chunksize=16)) # map keeps the input order
136
+
137
+
138
+ def report(result):
139
+ """A plain-words index report: what went in, what didn't, and why."""
140
+ n = len(result.included)
141
+ lines = [f"Indexed {n} file{'' if n == 1 else 's'}. Skipped {len(result.skipped)}."]
142
+ flagged = [s for s in result.skipped if s.reason.startswith("possible secret")]
143
+ if flagged:
144
+ lines.append("")
145
+ lines.append("Skipped because they may hold a secret. Check each one; if it's safe, add it to [secrets] allow:")
146
+ lines += [f" {s.path} ({s.reason})" for s in flagged]
147
+ counts = {}
148
+ for s in result.skipped:
149
+ if not s.reason.startswith("possible secret"):
150
+ counts[s.reason] = counts.get(s.reason, 0) + 1
151
+ if counts:
152
+ lines.append("")
153
+ lines.append("Other skips:")
154
+ lines += [f" {reason}: {n}" for reason, n in sorted(counts.items())]
155
+ return "\n".join(lines)
code_recycle/index.py ADDED
@@ -0,0 +1,101 @@
1
+ """Build the search index: one SQLite file with a full-text (FTS5) table of chunks.
2
+
3
+ Sources: SQLite FTS5 page, overview examples, UNINDEXED column option, porter tokenizer
4
+ (research plan Topic 6). Each build writes a new file and swaps it in, so a failed build never
5
+ leaves a half-written index behind.
6
+ """
7
+ import os # replacing the old index file in one step
8
+ import re # splitting camelCase names
9
+ import sqlite3 # Python's built-in SQLite, which includes FTS5 on most builds
10
+ from pathlib import Path # paths that work on every platform
11
+
12
+ from . import chunks, embed, files # splitting files into pieces; meaning search; walking the folders
13
+
14
+ # Searchable columns first, then stored-only ones. Order matters: bm25 weights follow it (search.py).
15
+ COLUMNS = ["name", "path", "url", "taught", "place", "code", "body"]
16
+ STORED = ["kind", "folder", "start", "end", "label"] # label: the real name, since "name" holds the split-up form
17
+ SCHEMA = (
18
+ "CREATE VIRTUAL TABLE chunks USING fts5("
19
+ + ", ".join(COLUMNS)
20
+ + ", "
21
+ + ", ".join(f"{c} UNINDEXED" for c in STORED) # kept for results, never matched
22
+ + ", tokenize = 'porter unicode61')" # stemming on top of the default tokenizer
23
+ )
24
+
25
+
26
+ class IndexBuildError(RuntimeError):
27
+ """The index can't be built or opened. The message says what to do."""
28
+
29
+
30
+ def check_fts5():
31
+ """Fail early with a clear message if this Python's SQLite was built without FTS5."""
32
+ try:
33
+ con = sqlite3.connect(":memory:")
34
+ con.execute("CREATE VIRTUAL TABLE t USING fts5(x)")
35
+ con.close()
36
+ except sqlite3.OperationalError:
37
+ raise IndexBuildError(
38
+ "This Python's SQLite has no FTS5 full-text search, which code_recycle needs. "
39
+ "Use the python.org installer, Homebrew or your Linux distribution's Python, which include it."
40
+ )
41
+
42
+
43
+ def split_name(name):
44
+ # "loadSettings" and "HTTPServer" become "load Settings" and "HTTP Server", so either word matches
45
+ return re.sub(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", " ", name)
46
+
47
+
48
+ def build(settings, progress=print):
49
+ """Walk, chunk and index every folder. Returns the walk result for the index report."""
50
+ check_fts5()
51
+ walked = files.walk(settings)
52
+ target = Path(settings.index_path)
53
+ target.parent.mkdir(parents=True, exist_ok=True) # e.g. create .code_recycle/
54
+ tmp = target.with_name(target.name + ".building") # built aside, then swapped in
55
+ if tmp.exists():
56
+ tmp.unlink() # left over from a build that was interrupted
57
+ con = sqlite3.connect(tmp)
58
+ try:
59
+ con.execute(SCHEMA)
60
+ con.execute("CREATE TABLE vectors (id INTEGER PRIMARY KEY, v BLOB)") # meaning vectors, same rowid as chunks
61
+ model = embed.load_model(settings.embed_model) if settings.embed_model else None
62
+ to_embed = [] # (rowid, text) for the pieces that get a meaning vector
63
+ rows = 0
64
+ for folder, rel in walked.included:
65
+ try:
66
+ text = (folder / rel).read_text(encoding="utf-8")
67
+ except UnicodeDecodeError:
68
+ text = (folder / rel).read_text(encoding="utf-8", errors="replace") # still searchable
69
+ for c in chunks.chunk_file(rel, text):
70
+ f = c.fields
71
+ cur = con.execute(
72
+ f"INSERT INTO chunks ({', '.join(COLUMNS + STORED)}) VALUES ({', '.join('?' * (len(COLUMNS) + len(STORED)))})",
73
+ (split_name(c.name), rel, f.get("url", ""), f.get("taught", ""), f.get("where", ""),
74
+ f.get("code", ""), c.text, c.kind, str(folder), c.start, c.end, c.name),
75
+ )
76
+ rows += 1
77
+ if model and c.kind in embed.EMBEDDED_KINDS:
78
+ to_embed.append((cur.lastrowid, embed.piece_text(c.name, c.text, f.get("taught", ""),
79
+ f.get("where", ""), f.get("code", ""))))
80
+ if to_embed:
81
+ progress(f"Embedding {len(to_embed)} pieces with {settings.embed_model}...")
82
+ vecs = embed.embed_pieces(model, [t for _, t in to_embed])
83
+ con.executemany("INSERT INTO vectors (id, v) VALUES (?, ?)",
84
+ [(rid, v.tobytes()) for (rid, _), v in zip(to_embed, vecs)])
85
+ con.execute("CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT)")
86
+ con.execute("INSERT INTO meta VALUES ('embed_model', ?)", (settings.embed_model,)) # search must use the same model
87
+ con.commit()
88
+ finally:
89
+ con.close()
90
+ os.replace(tmp, target) # one step, so readers see the old index or the new one, never half of one
91
+ progress(f"Indexed {rows} pieces from {len(walked.included)} files into {target}")
92
+ return walked
93
+
94
+
95
+ def connect(settings):
96
+ """Open an existing index read-only."""
97
+ path = Path(settings.index_path)
98
+ if not path.exists():
99
+ raise IndexBuildError(f"No index at {path}. Run 'code-recycle index' first.")
100
+ # as_uri() percent-encodes spaces, "#" and "?", and writes Windows drive letters correctly
101
+ return sqlite3.connect(path.resolve().as_uri() + "?mode=ro", uri=True) # read-only: searching never changes it
code_recycle/search.py ADDED
@@ -0,0 +1,176 @@
1
+ """Search the index, apply the reuse rules, and scan every result for secrets before returning it.
2
+
3
+ Ranking: SQLite FTS5 bm25() with a weight per column (research plan Topic 6). The weights are a
4
+ starting guess (original applied work), to be tuned only on the tuning half of the test set.
5
+ Reuse rules (no_suggest_from, no_suggest_into) are original applied work (Topic 9).
6
+ """
7
+ import re # turning free text into a safe FTS5 query
8
+ from dataclasses import dataclass, field # plain records for results
9
+ from pathlib import Path # finding which indexed folder a path belongs to
10
+ from pathspec import GitIgnoreSpec # the same pattern language as the skip rules
11
+
12
+ from . import embed, index, secret_check
13
+
14
+ # One weight per searchable column, in index.COLUMNS order: name, path, url, taught, place, code, body.
15
+ WEIGHTS = (5.0, 2.0, 3.0, 4.0, 1.0, 2.0, 1.0)
16
+ SNIPPET_LINES = 40 # lines of code shown per result; the full text is in the file at the given lines
17
+ POOL = 200 # candidates taken from each ranking before rules and de-duplication
18
+ STOPWORDS = {"a", "an", "and", "the", "to", "of", "in", "for", "on", "with", "how", "do", "i", "is", "it", "my", "what"}
19
+
20
+
21
+ @dataclass
22
+ class Result:
23
+ path: str # file path relative to its folder
24
+ folder: str # the indexed folder it came from
25
+ start: int # first line
26
+ end: int # last line
27
+ kind: str # function, class, research_entry, section, ...
28
+ name: str # function or class name, or heading
29
+ url: str = "" # research entries: where the source lives online
30
+ where: str = "" # research entries: where on that page
31
+ taught: str = "" # research entries: what it taught
32
+ snippet: str = "" # the code pattern (research entries) or the chunk's text
33
+ withheld: list = field(default_factory=list) # secret-check hits; the snippet is withheld if any
34
+
35
+
36
+ @dataclass
37
+ class Answer:
38
+ results: list # Result records, best first
39
+ note: str = "" # anything the user should know, such as a rule that hid matches
40
+
41
+
42
+ def to_query(text):
43
+ """Free text to an FTS5 query: every word, quoted so punctuation can't break the syntax, joined by OR."""
44
+ words = [w for w in re.findall(r"[A-Za-z0-9]+", index.split_name(text).lower()) if w not in STOPWORDS]
45
+ return " OR ".join(f'"{w}"' for w in dict.fromkeys(words)) # dict keeps order and drops repeats
46
+
47
+
48
+ def search(settings, text, from_path=None, limit=5):
49
+ """Best matches for `text`. `from_path` is where the search is made from, so reuse rules can apply."""
50
+ q = to_query(text)
51
+ if not q:
52
+ return Answer([], "The search had no usable words.")
53
+ con = index.connect(settings)
54
+ try:
55
+ keyword = con.execute(
56
+ f"SELECT rowid FROM chunks WHERE chunks MATCH ? ORDER BY bm25(chunks, {', '.join(map(str, WEIGHTS))}) LIMIT ?",
57
+ (q, POOL),
58
+ ).fetchall()
59
+ order = [r for (r,) in keyword]
60
+ if settings.embed_model:
61
+ order = embed.fuse(order, _meaning(con, settings, text)) # keyword and meaning, combined by RRF
62
+ order = order[:POOL]
63
+ found = {}
64
+ for rid in order:
65
+ found[rid] = con.execute(
66
+ "SELECT label, path, url, taught, place, code, body, kind, folder, start, end FROM chunks WHERE rowid = ?",
67
+ (rid,)).fetchone()
68
+ rows = [found[r] for r in order]
69
+ finally:
70
+ con.close()
71
+
72
+ blocked_from = GitIgnoreSpec.from_lines(settings.no_suggest_from)
73
+ into_spec = GitIgnoreSpec.from_lines(settings.no_suggest_into)
74
+ protected = _protected_area(settings, from_path, into_spec)
75
+
76
+ out, hidden, seen = [], 0, set()
77
+ for name, path, url, taught, place, code, body, kind, folder, start, end in rows:
78
+ # Rules first, then de-duplication: a blocked copy must never hide an allowed one
79
+ if blocked_from.match_file(path):
80
+ continue # never suggested from here, and not counted: the user asked for silence
81
+ if protected is not None and not (folder == protected[0] and path.startswith(protected[1])):
82
+ hidden += 1 # a match outside the protected area: say it exists, but don't show it
83
+ continue
84
+ if body in seen:
85
+ continue # an identical copy (a backup or a vendored file) adds nothing; show the best-ranked one
86
+ seen.add(body)
87
+ r = Result(path, folder, int(start), int(end), kind, name, url, place, taught, code or _first_lines(body))
88
+ r.withheld = sorted({hit for t in (r.snippet, r.url, r.where, r.taught, r.name) for hit in secret_check.scan_text(t)})
89
+ if r.withheld:
90
+ r.snippet = r.url = r.where = r.taught = "" # fail closed: show only where it is, not what it says
91
+ out.append(r)
92
+ if len(out) == limit:
93
+ break
94
+ note = ""
95
+ if hidden:
96
+ note = (f"{hidden} matches exist outside this folder, but the no_suggest_into rule says not to "
97
+ f"suggest them here.")
98
+ return Answer(out, note)
99
+
100
+
101
+ _vector_cache = {} # index file -> (modified time, rowids, matrix, model), so vectors load once per server
102
+
103
+
104
+ def _meaning(con, settings, text):
105
+ """Rowids ranked by closeness in meaning to the query."""
106
+ import numpy as np
107
+ try:
108
+ stored = con.execute("SELECT value FROM meta WHERE key = 'embed_model'").fetchone()
109
+ except __import__("sqlite3").OperationalError:
110
+ stored = None # an index from before embeddings existed
111
+ if not stored or stored[0] != settings.embed_model:
112
+ raise index.IndexBuildError(
113
+ f"The index was built with embeddings model {stored[0] if stored else 'none'!r}, but settings say "
114
+ f"{settings.embed_model!r}. Run 'code-recycle index' again.")
115
+ key = str(settings.index_path)
116
+ mtime = Path(key).stat().st_mtime
117
+ cached = _vector_cache.get(key)
118
+ if not cached or cached[0] != mtime:
119
+ rows = con.execute("SELECT id, v FROM vectors").fetchall()
120
+ ids = [r for r, _ in rows]
121
+ matrix = np.frombuffer(b"".join(v for _, v in rows), dtype=np.float32).reshape(len(rows), -1) if rows else None
122
+ cached = (mtime, ids, matrix, embed.load_model(settings.embed_model))
123
+ _vector_cache[key] = cached
124
+ _, ids, matrix, model = cached
125
+ if matrix is None:
126
+ return []
127
+ return embed.nearest(matrix, ids, embed.embed_query(model, text), POOL)
128
+
129
+
130
+ def _protected_area(settings, from_path, into_spec):
131
+ """If the search comes from inside a no_suggest_into folder: (indexed folder, that folder's path
132
+ with a trailing slash). Otherwise None. Only results inside that same area may be shown."""
133
+ if not from_path:
134
+ return None
135
+ p = Path(from_path).expanduser().resolve()
136
+ for folder in settings.folders:
137
+ try:
138
+ rel = p.relative_to(folder).as_posix()
139
+ except ValueError:
140
+ continue # not inside this indexed folder
141
+ parts = [] if rel == "." else rel.split("/")
142
+ for i in range(1, len(parts) + 1): # the shortest enclosing folder the rule names
143
+ area = "/".join(parts[:i]) + "/"
144
+ if into_spec.match_file(area):
145
+ return str(folder), area
146
+ return None
147
+
148
+
149
+ def _first_lines(text):
150
+ lines = text.splitlines()
151
+ more = len(lines) - SNIPPET_LINES
152
+ return "\n".join(lines[:SNIPPET_LINES]) + (f"\n... ({more} more lines)" if more > 0 else "")
153
+
154
+
155
+ def format_answer(answer):
156
+ """Plain text an AI client or a person can read: where each result is, and what it says."""
157
+ if not answer.results:
158
+ return answer.note or "No matches in your own work. Searching the web is fine."
159
+ parts = []
160
+ for i, r in enumerate(answer.results, start=1):
161
+ head = f"{i}. {r.name or r.path} ({r.kind}) {Path(r.folder) / r.path}:{r.start}-{r.end}"
162
+ lines = [head]
163
+ if r.url:
164
+ lines.append(f" Source: {r.url}")
165
+ if r.where:
166
+ lines.append(f" Where on the page: {r.where}")
167
+ if r.taught:
168
+ lines.append(f" What it taught: {r.taught}")
169
+ if r.withheld:
170
+ lines.append(f" Content withheld: it may contain a secret ({', '.join(r.withheld)}). Open the file to check.")
171
+ elif r.snippet:
172
+ lines.append(" ```\n" + "\n".join(" " + l for l in r.snippet.splitlines()) + "\n ```")
173
+ parts.append("\n".join(lines))
174
+ if answer.note:
175
+ parts.append(answer.note)
176
+ return "\n\n".join(parts)
@@ -0,0 +1,99 @@
1
+ """Keep secrets out of the index and out of every result.
2
+
3
+ The policy is original applied work (research plan Topic 10, after the GPT check). It promises
4
+ only what the tests prove:
5
+ 1. Files with a known secret name are never opened.
6
+ 2. Every other file is scanned with detect-secrets before it is read in. A flagged file is
7
+ skipped and listed in the index report, unless the user allows it by name.
8
+ 3. Every field returned to a client is scanned again with a small built-in pattern list.
9
+
10
+ Source for step 2: detect-secrets README, "Usage in Other Python Scripts", "Basic Use" and
11
+ "More Advanced Configuration" (choosing detectors with plugins_used).
12
+ By default only named-credential detectors run. The two "high entropy" detectors flag any
13
+ random-looking text (hashes, logs, notebook images), so they only run with strict = true.
14
+ """
15
+ import os # removing the canary file
16
+ import re # the built-in patterns for step 3
17
+ import tempfile # a throwaway file for the canary check
18
+ from contextlib import contextmanager # lets one scanner set-up serve a whole walk
19
+ from pathspec import GitIgnoreSpec # gitignore-style matching, research plan Topic 9
20
+ from detect_secrets import SecretsCollection # holds whatever one scan finds
21
+ from detect_secrets.settings import transient_settings # run the scanner with a chosen set of detectors
22
+
23
+ # Step 1: names that hold secrets by convention. Never opened, and allow-listing can't override this.
24
+ SECRET_NAMES = GitIgnoreSpec.from_lines([
25
+ ".env", ".env.*", "*.pem", "*.key", "*.p12", "*.pfx", "id_rsa", "id_dsa", "id_ecdsa", "id_ed25519",
26
+ ".netrc", ".pypirc", ".npmrc", "credentials.json", "*.keystore", "*.jks",
27
+ ])
28
+
29
+ # Step 3: patterns for the commonest live credentials. Short on purpose; the file scan is the main
30
+ # defence and this is the second line. Each entry: (name shown to the user, compiled pattern).
31
+ TEXT_PATTERNS = [
32
+ ("AWS access key", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b")),
33
+ ("GitHub token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b")),
34
+ ("GitHub fine-grained token", re.compile(r"\bgithub_pat_[A-Za-z0-9_]{22,}\b")),
35
+ ("Slack token", re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b")),
36
+ ("OpenAI-style key", re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b")),
37
+ ("Private key block", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
38
+ ("Telegram bot token", re.compile(r"\b\d{8,10}:[A-Za-z0-9_-]{35}\b")),
39
+ ]
40
+
41
+ # Step 2 detectors, named as in detect-secrets 1.5.0 (pinned in pyproject.toml).
42
+ NAMED_DETECTORS = [
43
+ "ArtifactoryDetector", "AWSKeyDetector", "AzureStorageKeyDetector", "BasicAuthDetector",
44
+ "CloudantDetector", "DiscordBotTokenDetector", "GitHubTokenDetector", "GitLabTokenDetector",
45
+ "IbmCloudIamDetector", "IbmCosHmacDetector", "JwtTokenDetector", "KeywordDetector",
46
+ "MailchimpDetector", "NpmDetector", "OpenAIDetector", "PrivateKeyDetector", "PypiTokenDetector",
47
+ "SendGridDetector", "SlackDetector", "SoftlayerDetector", "SquareOAuthDetector", "StripeDetector",
48
+ "TelegramBotTokenDetector", "TwilioKeyDetector",
49
+ ]
50
+ ENTROPY_DETECTORS = ["Base64HighEntropyString", "HexHighEntropyString"] # only with strict = true
51
+
52
+
53
+ def is_secret_name(relpath):
54
+ """True if this path's name marks it as a secrets file (step 1)."""
55
+ return SECRET_NAMES.match_file(relpath) # matches the name at any depth, as .gitignore would
56
+
57
+
58
+ @contextmanager
59
+ def file_scanner(strict=False):
60
+ """Set the scanner up once and yield a function that scans one file (step 2).
61
+
62
+ The function returns the names of detectors that fired; an empty list means nothing was found.
63
+ """
64
+ names = NAMED_DETECTORS + (ENTROPY_DETECTORS if strict else [])
65
+ with transient_settings({"plugins_used": [{"name": n} for n in names]}): # only these detectors run
66
+ def scan(path):
67
+ found = SecretsCollection() # a fresh collection per file, so results never mix
68
+ found.scan_file(str(path)) # scans the file's lines; it never changes the file
69
+ return sorted({hit["type"] for hits in found.json().values() for hit in hits}) # unique names
70
+ _canary(scan) # fail closed: refuse to run if the scanner can't see a planted fake key
71
+ yield scan
72
+
73
+
74
+ class ScannerBroken(RuntimeError):
75
+ """The secret scanner missed a planted fake key, so nothing is indexed rather than risk a leak."""
76
+
77
+
78
+ def _canary(scan):
79
+ # AWS's documented example key, built from pieces so this file holds no key-shaped text
80
+ fake = "AKIA" + "IOSFODNN7" + "EXAMPLE"
81
+ fd, path = tempfile.mkstemp(suffix=".py")
82
+ try:
83
+ with os.fdopen(fd, "w") as f:
84
+ f.write(f'key = "{fake}"\n')
85
+ if "AWS Access Key" not in scan(path):
86
+ raise ScannerBroken("The secret scanner failed its self-check. Nothing was indexed.")
87
+ finally:
88
+ os.remove(path)
89
+
90
+
91
+ def scan_file(path, strict=False):
92
+ """Scan a single file. For many files, use file_scanner so set-up happens once."""
93
+ with file_scanner(strict) as scan:
94
+ return scan(path)
95
+
96
+
97
+ def scan_text(text):
98
+ """Pattern names that match this text (step 3). An empty list means it's safe to return."""
99
+ return [name for name, pattern in TEXT_PATTERNS if pattern.search(text)]
code_recycle/server.py ADDED
@@ -0,0 +1,42 @@
1
+ """The MCP server: one read-only search tool that any MCP client can call.
2
+
3
+ Source: MCP Python SDK v2 (pinned to 2.3.0), "Connect to a real host" example and README
4
+ (research plan Topic 1). mcp.run() with no arguments serves over stdio, which every host speaks.
5
+ """
6
+ import os # the folder the host started the server in
7
+
8
+ from mcp.server import MCPServer # the SDK's server class
9
+ from mcp.server.mcpserver.exceptions import ToolError # a clean error the client can show
10
+
11
+ from . import index, search, settings as settings_mod
12
+
13
+
14
+ def build_server(settings_path):
15
+ """An MCP server bound to one settings file. Settings are read now, so mistakes show at start-up."""
16
+ s = settings_mod.load(settings_path)
17
+ mcp = MCPServer("code_recycle")
18
+
19
+ @mcp.tool()
20
+ def search_own_work(query: str, from_path: str = "", limit: int = 5) -> str:
21
+ """Search the user's own code and verified research sources before searching the web.
22
+
23
+ Use this first when about to write code, or to look up a library or API: the user may have
24
+ solved it before. Results give the file and line numbers, and for research entries the
25
+ source URL, where on the page, what it taught and the verified code copied from it.
26
+
27
+ query: what you need, in plain words, for example "retry an HTTP request with backoff".
28
+ from_path: the file or folder you are working in, so the user's reuse rules can apply.
29
+ If left out, the folder the server was started in is used.
30
+ limit: how many results to return, 1 to 20.
31
+ """
32
+ if not 1 <= limit <= 20:
33
+ raise ToolError("limit must be between 1 and 20")
34
+ try:
35
+ # Hosts start the server in the project's folder, so that's the safe default when the
36
+ # caller doesn't say: a rule like no_suggest_into still applies if from_path is left out
37
+ answer = search.search(s, query, from_path=from_path or os.getcwd(), limit=limit)
38
+ except index.IndexBuildError as e:
39
+ raise ToolError(str(e))
40
+ return search.format_answer(answer)
41
+
42
+ return mcp
@@ -0,0 +1,151 @@
1
+ """Read and check the settings file.
2
+
3
+ Source: Python docs, tomllib, "Examples" (research plan Topic 8).
4
+ The layout of the file is original applied work (project plan, "Original design").
5
+ """
6
+ import tomllib # standard-library TOML reader, built in from Python 3.11
7
+ from dataclasses import dataclass, field # a plain record type for the checked settings
8
+ from pathlib import Path # path handling that works the same on Linux, macOS and Windows
9
+
10
+ # Every key the settings file may use, per section. Anything else is a typo and is refused,
11
+ # because a misspelt rule (say "no_sugest_into") would otherwise be silently ignored.
12
+ ALLOWED = {
13
+ "index": {"folders", "include", "skip", "use_gitignore", "max_file_kb", "index_path"},
14
+ "rules": {"no_suggest_from", "no_suggest_into"},
15
+ "secrets": {"allow", "strict"},
16
+ "embeddings": {"model"},
17
+ }
18
+
19
+ # Code and text file types indexed when the settings file doesn't say. Data files (JSON, CSV),
20
+ # notebooks and logs are left out on purpose: they are big, noisy and rarely worth reusing.
21
+ DEFAULT_INCLUDE = [
22
+ "*.py", "*.pyi", "*.md", "*.rst", "*.txt", "*.toml", "*.yaml", "*.yml", "*.cfg", "*.ini",
23
+ "*.sh", "*.bash", "*.ps1", "*.sql", "*.js", "*.mjs", "*.ts", "*.tsx", "*.jsx", "*.java",
24
+ "*.kt", "*.go", "*.rs", "*.c", "*.h", "*.cpp", "*.hpp", "*.cs", "*.rb", "*.php", "*.r", "*.R",
25
+ "*.swift", "*.scala", "*.lua", "*.html", "*.css", "Dockerfile", "Makefile", "*.mk",
26
+ ]
27
+
28
+ EXAMPLE = """\
29
+ # code_recycle settings. Paths may start with ~ or be relative to this file.
30
+
31
+ [index]
32
+ folders = ["~/projects"] # folders to search; each one is indexed separately
33
+ # include = ["*.py", "*.md"] # file types to index; leave out to use the built-in list of code and text types
34
+ skip = ["node_modules/", "*.min.js", "data/"] # gitignore-style patterns, relative to each folder
35
+ use_gitignore = true # also honour the .gitignore at the top of each folder
36
+ max_file_kb = 1024 # bigger files are skipped and listed in the index report
37
+ index_path = ".code_recycle/index.db" # where the index is stored
38
+
39
+ [rules]
40
+ # Patterns are relative to each indexed folder, in gitignore style.
41
+ no_suggest_from = [] # never return results that live here
42
+ no_suggest_into = [] # searches made from here only say "matches exist elsewhere"
43
+
44
+ [secrets]
45
+ allow = [] # files the secret scanner flagged that you have checked and allow
46
+ strict = false # true also flags random-looking text: safer, but many false alarms
47
+
48
+ [embeddings]
49
+ # Search by meaning as well as by keyword. Needs: pip install "code-recycle[embed]"
50
+ # The model (about 67 MB) downloads once, then everything runs offline.
51
+ model = "" # "" for keyword only, or "BAAI/bge-small-en-v1.5"
52
+ """
53
+
54
+
55
+ class SettingsError(ValueError):
56
+ """The settings file is missing, unreadable or has a mistake. The message says what to fix."""
57
+
58
+
59
+ @dataclass
60
+ class Settings:
61
+ folders: list[Path] # absolute folders to index
62
+ skip: list[str] = field(default_factory=list) # extra gitignore-style skip patterns
63
+ use_gitignore: bool = True # honour each folder's top-level .gitignore
64
+ max_file_kb: int = 1024 # size limit per file, in kilobytes
65
+ index_path: Path = Path(".code_recycle/index.db") # absolute path of the index file
66
+ no_suggest_from: list[str] = field(default_factory=list) # results never come from here
67
+ no_suggest_into: list[str] = field(default_factory=list) # queries from here get no suggestions
68
+ secrets_allow: list[str] = field(default_factory=list) # scanner-flagged files the user allows
69
+ secrets_strict: bool = False # also run the high-entropy detectors
70
+ include: list[str] = field(default_factory=lambda: list(DEFAULT_INCLUDE)) # file types to index
71
+ embed_model: str = "" # "" means keyword search only
72
+
73
+
74
+ def _str_list(value, name):
75
+ # A list of strings is the only shape a pattern or folder list may take
76
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
77
+ raise SettingsError(f"'{name}' must be a list of strings, for example [\"src/\"]")
78
+ return value
79
+
80
+
81
+ def _resolve(raw, base):
82
+ # Expand ~ and make a relative path relative to the settings file, not the working directory,
83
+ # because an MCP host starts the tool from its own working directory
84
+ p = Path(raw).expanduser()
85
+ return p if p.is_absolute() else (base / p).resolve()
86
+
87
+
88
+ def load(path):
89
+ """Read a settings file and return checked Settings, or raise SettingsError saying what is wrong."""
90
+ path = Path(path).expanduser().resolve() # absolute, so error messages name the real file
91
+ try:
92
+ with open(path, "rb") as f: # tomllib needs the file opened in binary mode
93
+ data = tomllib.load(f) # parse the TOML into plain dicts and lists
94
+ except FileNotFoundError:
95
+ raise SettingsError(f"No settings file at {path}. Run 'code-recycle init' to write an example.")
96
+ except tomllib.TOMLDecodeError as e:
97
+ raise SettingsError(f"{path} is not valid TOML: {e}")
98
+
99
+ for section, keys in data.items(): # refuse unknown sections and keys, so typos are caught
100
+ if section not in ALLOWED:
101
+ raise SettingsError(f"Unknown section [{section}] in {path}. Allowed: {', '.join(ALLOWED)}")
102
+ if not isinstance(keys, dict):
103
+ raise SettingsError(f"[{section}] in {path} must be a table of settings")
104
+ for key in keys:
105
+ if key not in ALLOWED[section]:
106
+ raise SettingsError(f"Unknown setting '{key}' in [{section}]. Allowed: {', '.join(sorted(ALLOWED[section]))}")
107
+
108
+ index = data.get("index", {}) # the only section that must exist, because folders are required
109
+ rules = data.get("rules", {})
110
+ secrets = data.get("secrets", {})
111
+ base = path.parent # relative paths are read from the settings file's own folder
112
+
113
+ folders = [_resolve(f, base) for f in _str_list(index.get("folders", []), "folders")]
114
+ if not folders:
115
+ raise SettingsError("[index] folders is empty. List at least one folder to search.")
116
+ missing = [str(f) for f in folders if not f.is_dir()] # a folder that doesn't exist is a mistake
117
+ if missing:
118
+ raise SettingsError(f"These folders don't exist: {', '.join(missing)}")
119
+
120
+ use_gitignore = index.get("use_gitignore", True)
121
+ if not isinstance(use_gitignore, bool): # TOML true/false only, not "yes"
122
+ raise SettingsError("'use_gitignore' must be true or false")
123
+ max_kb = index.get("max_file_kb", 1024)
124
+ if not isinstance(max_kb, int) or isinstance(max_kb, bool) or max_kb <= 0: # bool is an int in Python
125
+ raise SettingsError("'max_file_kb' must be a whole number above 0")
126
+ strict = secrets.get("strict", False)
127
+ if not isinstance(strict, bool):
128
+ raise SettingsError("'strict' must be true or false")
129
+ include = _str_list(index.get("include", DEFAULT_INCLUDE), "include")
130
+ if not include:
131
+ raise SettingsError("[index] include is empty. Leave it out to use the built-in list.")
132
+ embed_model = data.get("embeddings", {}).get("model", "")
133
+ if not isinstance(embed_model, str):
134
+ raise SettingsError("[embeddings] model must be a model name in quotes, or \"\" for keyword only")
135
+ index_path = index.get("index_path", ".code_recycle/index.db")
136
+ if not isinstance(index_path, str):
137
+ raise SettingsError("'index_path' must be a path in quotes")
138
+
139
+ return Settings(
140
+ folders=folders,
141
+ skip=_str_list(index.get("skip", []), "skip"),
142
+ use_gitignore=use_gitignore,
143
+ max_file_kb=max_kb,
144
+ index_path=_resolve(index_path, base),
145
+ no_suggest_from=_str_list(rules.get("no_suggest_from", []), "no_suggest_from"),
146
+ no_suggest_into=_str_list(rules.get("no_suggest_into", []), "no_suggest_into"),
147
+ secrets_allow=_str_list(secrets.get("allow", []), "allow"),
148
+ secrets_strict=strict,
149
+ include=list(include),
150
+ embed_model=embed_model,
151
+ )
@@ -0,0 +1,176 @@
1
+ Metadata-Version: 2.5
2
+ Name: code-recycle
3
+ Version: 0.1.0
4
+ Summary: Search your own code and verified sources before searching the web. Local, private, and usable from any MCP client.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: detect-secrets==1.5.0
9
+ Requires-Dist: mcp==2.3.0
10
+ Requires-Dist: pathspec==1.1.1
11
+ Provides-Extra: dev
12
+ Requires-Dist: anyio; extra == 'dev'
13
+ Requires-Dist: numpy; extra == 'dev'
14
+ Requires-Dist: pyarrow==22.0.0; extra == 'dev'
15
+ Requires-Dist: pytest==9.1.1; extra == 'dev'
16
+ Provides-Extra: embed
17
+ Requires-Dist: fastembed==0.9.1; extra == 'embed'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # code_recycle
21
+
22
+ Search your own code and your own verified sources before you search the web.
23
+
24
+ Every new project I start, I end up looking for the same things again: how to retry a request,
25
+ how to read a settings file, which page had that bootstrap example. Most of the time I've already
26
+ solved it in another project, and I've already checked the source. I just can't find it.
27
+
28
+ Companies have the same problem, with a twist. Pasting internal code or questions into outside
29
+ tools is a risk, so the search needs to stay on the machine.
30
+
31
+ code_recycle indexes your folders and answers "have I already solved this?" with the file and line
32
+ numbers. If you keep research notes with a source URL and the code you copied from it, it finds
33
+ those too, with the link and what you learned. It runs locally, needs no account and no API key,
34
+ and plugs into Claude Code, Codex, Cursor or any MCP client.
35
+
36
+ ## Try it in 5 minutes
37
+
38
+ Needs Python 3.11 or newer.
39
+
40
+ ```bash
41
+ git clone https://github.com/Z33MScEssex/code_recycle
42
+ cd code_recycle
43
+ pip install .
44
+ cd examples/demo
45
+ code-recycle index
46
+ code-recycle search "read a settings file"
47
+ ```
48
+
49
+ The first result is a research note: the Python docs page for `tomllib`, where on the page, what it
50
+ taught, and the code copied from it. Try `code-recycle search "try a request again with a wait"`
51
+ and you get the `retry` function from `tools/http_utils.py`, lines 14 to 24.
52
+
53
+ ## Use it on your own folders
54
+
55
+ ```bash
56
+ code-recycle init # writes code_recycle.toml here; edit the folders list
57
+ code-recycle index # builds the index and prints what was skipped and why
58
+ code-recycle search "parse an iso date"
59
+ ```
60
+
61
+ Re-run `code-recycle index` after big changes. It reads everything again; on my laptop that's about
62
+ 40 seconds for 1,500 files.
63
+
64
+ ## Connect it to an AI coding assistant
65
+
66
+ The tool is called `search_own_work`. It's read-only. Hosts start it with a near-empty environment,
67
+ so give the settings file's full path.
68
+
69
+ Claude Code:
70
+
71
+ ```bash
72
+ claude mcp add code-recycle -e CODE_RECYCLE_SETTINGS=/full/path/to/code_recycle.toml -- code-recycle serve
73
+ ```
74
+
75
+ Codex:
76
+
77
+ ```bash
78
+ codex mcp add code-recycle --env CODE_RECYCLE_SETTINGS=/full/path/to/code_recycle.toml -- code-recycle serve
79
+ ```
80
+
81
+ If the host can't find `code-recycle`, use the full path from `which code-recycle` (or
82
+ `where code-recycle` on Windows).
83
+
84
+ ## Research notes it understands
85
+
86
+ A Markdown section becomes a research entry when it has a URL field. These labels are read, bold
87
+ or plain:
88
+
89
+ ```markdown
90
+ ## Topic 1: Read a TOML settings file
91
+
92
+ - **URL:** https://docs.python.org/3/library/tomllib.html
93
+ - **Where on the page:** "Examples", the first example.
94
+ - **What it taught:** the file must be opened in binary mode.
95
+ - **Code pattern from source:**
96
+ (a fenced code block)
97
+ ```
98
+
99
+ Everything else is still indexed: Python by function, method and class with exact line numbers,
100
+ other Markdown by heading, and other text files in overlapping windows of 60 lines.
101
+
102
+ ## How well it works
103
+
104
+ Measured, not assumed. The test design was written down before the first run, and nothing was
105
+ tuned on the test sets. Full write-up: [results/accuracy_2026-11-05.md](results/accuracy_2026-11-05.md).
106
+
107
+ | Test | What it asks | Result |
108
+ |---|---|---|
109
+ | CodeSearchNet, Python | Find a function from its docstring among 999 others (Husain et al. 2019) | MRR 0.797, right function in the top 5 87% of the time |
110
+ | My own research notes | Find another note that used the same source, from a topic heading | MRR 0.308, top 5 39% of the time, 51 cases |
111
+
112
+ The second test is the real job, and it's much harder. The same source often gets described in
113
+ different words in different projects, and keyword search can't bridge that.
114
+
115
+ I tried meaning-based search with a small local embedding model. It lifted CodeSearchNet to 0.907,
116
+ but it didn't help on my own notes (0.250 against 0.280 at the time), and the rule set beforehand
117
+ said it had to improve both. So v0.1 ships keyword search. The embeddings code is in the repo,
118
+ switched off and marked experimental: [results/prereg_embeddings.md](results/prereg_embeddings.md).
119
+
120
+ ## Privacy and secrets
121
+
122
+ - Nothing leaves your machine. There are no network calls, unless you switch on the experimental
123
+ embeddings, which download a model once.
124
+ - Files named like secrets (`.env`, `*.pem`, `id_rsa` and others) are never opened.
125
+ - Every other file is scanned with [detect-secrets](https://github.com/Yelp/detect-secrets) before
126
+ it's indexed. A flagged file is skipped and listed in the index report. If you've checked it's
127
+ safe, add it to `[secrets] allow`.
128
+ - Every result is scanned again before it's returned, for common live key formats (AWS, GitHub,
129
+ Slack, OpenAI-style, Telegram bot tokens, private key blocks). If one matches, the content is
130
+ withheld and only the location is shown.
131
+ - If the scanner fails its own self-check (it must spot a planted fake key), nothing is indexed.
132
+
133
+ Each of these behaviours has a test with fake keys. That isn't a guarantee: a pattern scanner can't
134
+ catch every possible secret, so don't point the tool at folders you know hold live credentials.
135
+
136
+ ## Reuse rules
137
+
138
+ Some folders shouldn't borrow from others. I have one project where every requirement must be
139
+ researched fresh, so code from other projects must never be suggested into it.
140
+
141
+ ```toml
142
+ [rules]
143
+ no_suggest_from = ["private/"] # never return results from here
144
+ no_suggest_into = ["clean-room/"] # searches made from here only see this folder's own code,
145
+ # plus a note that other matches exist
146
+ ```
147
+
148
+ The MCP tool takes a `from_path`, so the assistant can say where it's working. If it doesn't, the
149
+ folder the server was started in is used. The rules are a guard against accidents, not a security
150
+ boundary: they trust the path they're given.
151
+
152
+ ## Settings
153
+
154
+ `code-recycle init` writes a commented example. The settings file is found in this order:
155
+ `--settings`, then `$CODE_RECYCLE_SETTINGS`, then `./code_recycle.toml`, then
156
+ `~/.config/code_recycle/code_recycle.toml`. Typos are refused with a clear message, because a
157
+ misspelt rule silently ignored would be worse.
158
+
159
+ ## Limits
160
+
161
+ - Python is the only language split by function. Others are searched in windows of lines.
162
+ - The index is rebuilt in full each time. Fine for a few thousand files; not built for huge monorepos.
163
+ - Search is keyword-based (SQLite FTS5 with stemming), so it finds shared words, not shared ideas.
164
+ - So far only I have used it. It has had no outside users yet.
165
+
166
+ ## Development
167
+
168
+ ```bash
169
+ pip install -e ".[dev]"
170
+ pytest
171
+ ```
172
+
173
+ 87 tests. The CI workflow runs them on Linux, macOS and Windows with Python 3.11 to 3.13 on every push. How sources were
174
+ checked and how accuracy was measured: [METHOD.md](METHOD.md). Benchmarks: [bench/README.md](bench/README.md).
175
+
176
+ MIT licence.
@@ -0,0 +1,15 @@
1
+ code_recycle/__init__.py,sha256=DIXyXkoxhDCDmwRFVwoY9ea-D1EH7KECZ5YKUkSjFP0,147
2
+ code_recycle/chunks.py,sha256=GjVb_x3DBqsr7TEutfJIKkr9qd1kDHXpr49AXfoK9zY,7637
3
+ code_recycle/cli.py,sha256=ehTgWCpHuG4LBSS768DyIRjb37Nb8oVnvuYWKmVt1HA,3126
4
+ code_recycle/embed.py,sha256=4jFXy1ZJ-ALwGjJWOjddDNrN2GoVXM17KOUVwHFwx4s,3242
5
+ code_recycle/files.py,sha256=ygdzX87d-4E7aC2OQLXf3EncA6PbpK7YI3zEAbF_qcs,7073
6
+ code_recycle/index.py,sha256=SMYuVQtd_7a7DNRaTNauMgPSmd9cGbNPuyMyAbvwQ1w,5163
7
+ code_recycle/search.py,sha256=FWB_De2NikfuwcCf3DwPIYm1eKshCJJWGi2M2mDk6ts,8353
8
+ code_recycle/secret_check.py,sha256=lJXAKK-AH4Kse7wSsaqJGka51WKzs-LIP195r72I324,5171
9
+ code_recycle/server.py,sha256=0y9k8uelWP5hSSXXu8u3y04nnnq0-A0qJXvs_XFco4I,2090
10
+ code_recycle/settings.py,sha256=5wZ32AEevGXVu8_DglAcGHMq0yKBMfwxUy0-okkZKHU,7993
11
+ code_recycle-0.1.0.dist-info/METADATA,sha256=f4-ggLDRMwMF9ww5fhQf-DM2Y00w6tTbUJqk_uY8zZI,7420
12
+ code_recycle-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
13
+ code_recycle-0.1.0.dist-info/entry_points.txt,sha256=xvVUqifZ8QOCIPK9f7uj61LL9GP8pDcGnZ7f7wvPYDQ,55
14
+ code_recycle-0.1.0.dist-info/licenses/LICENSE,sha256=4Y_gs5zJAlvyws-IC3gat3Q6aLzHaxruEtfNY-ogpQc,1068
15
+ code_recycle-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ code-recycle = code_recycle.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zee Mehmood
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.