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.
- code_recycle/__init__.py +3 -0
- code_recycle/chunks.py +168 -0
- code_recycle/cli.py +69 -0
- code_recycle/embed.py +68 -0
- code_recycle/files.py +155 -0
- code_recycle/index.py +101 -0
- code_recycle/search.py +176 -0
- code_recycle/secret_check.py +99 -0
- code_recycle/server.py +42 -0
- code_recycle/settings.py +151 -0
- code_recycle-0.1.0.dist-info/METADATA +176 -0
- code_recycle-0.1.0.dist-info/RECORD +15 -0
- code_recycle-0.1.0.dist-info/WHEEL +4 -0
- code_recycle-0.1.0.dist-info/entry_points.txt +2 -0
- code_recycle-0.1.0.dist-info/licenses/LICENSE +21 -0
code_recycle/__init__.py
ADDED
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
|
code_recycle/settings.py
ADDED
|
@@ -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,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.
|