misterdev 0.2.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.
- misterdev/__init__.py +3 -0
- misterdev/agent.py +2166 -0
- misterdev/agent_helpers.py +194 -0
- misterdev/analyzers/__init__.py +0 -0
- misterdev/analyzers/project_analyzer/__init__.py +246 -0
- misterdev/analyzers/project_analyzer/detection.py +146 -0
- misterdev/analyzers/project_analyzer/merge.py +137 -0
- misterdev/analyzers/project_analyzer/overview.py +312 -0
- misterdev/analyzers/project_analyzer/prompts.py +83 -0
- misterdev/cli.py +370 -0
- misterdev/config.py +521 -0
- misterdev/core/__init__.py +0 -0
- misterdev/core/audit.py +88 -0
- misterdev/core/config.py +10 -0
- misterdev/core/context/__init__.py +0 -0
- misterdev/core/context/change_tracker.py +218 -0
- misterdev/core/context/contracts/__init__.py +63 -0
- misterdev/core/context/contracts/_log.py +9 -0
- misterdev/core/context/contracts/_text.py +12 -0
- misterdev/core/context/contracts/extraction.py +30 -0
- misterdev/core/context/contracts/python_generic.py +42 -0
- misterdev/core/context/contracts/registry.py +127 -0
- misterdev/core/context/contracts/rust_line.py +225 -0
- misterdev/core/context/contracts/rust_tree_sitter.py +141 -0
- misterdev/core/context/lsp.py +174 -0
- misterdev/core/context/scratchpad.py +111 -0
- misterdev/core/context/topography/__init__.py +43 -0
- misterdev/core/context/topography/_log.py +10 -0
- misterdev/core/context/topography/cache.py +91 -0
- misterdev/core/context/topography/engine.py +172 -0
- misterdev/core/context/topography/graph.py +675 -0
- misterdev/core/context/topography/nodes.py +55 -0
- misterdev/core/context/topography/parsers.py +95 -0
- misterdev/core/context/topography/syntax.py +54 -0
- misterdev/core/economics/__init__.py +0 -0
- misterdev/core/economics/context_budget.py +175 -0
- misterdev/core/economics/embeddings.py +232 -0
- misterdev/core/economics/free_models.py +108 -0
- misterdev/core/economics/llm_cache.py +105 -0
- misterdev/core/economics/model_catalog.py +79 -0
- misterdev/core/economics/model_ledger.py +331 -0
- misterdev/core/economics/model_selector.py +281 -0
- misterdev/core/execution/__init__.py +0 -0
- misterdev/core/execution/bounded.py +50 -0
- misterdev/core/execution/container.py +221 -0
- misterdev/core/execution/error_classifier.py +366 -0
- misterdev/core/execution/error_resolver.py +201 -0
- misterdev/core/execution/governance.py +283 -0
- misterdev/core/execution/outcomes.py +50 -0
- misterdev/core/execution/progress.py +120 -0
- misterdev/core/execution/project.py +231 -0
- misterdev/core/execution/registry.py +97 -0
- misterdev/core/execution/runtime.py +279 -0
- misterdev/core/gitcmd.py +39 -0
- misterdev/core/integration/__init__.py +0 -0
- misterdev/core/integration/mcp.py +368 -0
- misterdev/core/integration/mcp_gather.py +186 -0
- misterdev/core/models.py +35 -0
- misterdev/core/modes.py +184 -0
- misterdev/core/planning/__init__.py +0 -0
- misterdev/core/planning/advisor.py +89 -0
- misterdev/core/planning/assessment.py +135 -0
- misterdev/core/planning/decomposer.py +387 -0
- misterdev/core/planning/metacognition.py +103 -0
- misterdev/core/planning/sovereign.py +308 -0
- misterdev/core/planning/targets.py +201 -0
- misterdev/core/reporting/__init__.py +0 -0
- misterdev/core/reporting/report.py +377 -0
- misterdev/core/reporting/report_view.py +151 -0
- misterdev/core/task.py +163 -0
- misterdev/core/verification/__init__.py +0 -0
- misterdev/core/verification/claim_verifier.py +210 -0
- misterdev/core/verification/critic.py +324 -0
- misterdev/core/verification/gatekeeper/__init__.py +631 -0
- misterdev/core/verification/gatekeeper/constants.py +138 -0
- misterdev/core/verification/gatekeeper/helpers.py +28 -0
- misterdev/core/verification/goal_check.py +219 -0
- misterdev/core/verification/independent.py +68 -0
- misterdev/core/verification/mutation_gate.py +221 -0
- misterdev/core/verification/preflight.py +95 -0
- misterdev/core/verification/spec_tests.py +175 -0
- misterdev/core/verification/validator.py +495 -0
- misterdev/core/verification/vision_verify.py +185 -0
- misterdev/core/verification/web_verify.py +408 -0
- misterdev/environments/__init__.py +0 -0
- misterdev/environments/base_env.py +18 -0
- misterdev/environments/container_env.py +87 -0
- misterdev/environments/venv_env.py +42 -0
- misterdev/llm/__init__.py +0 -0
- misterdev/llm/client/__init__.py +152 -0
- misterdev/llm/client/base.py +382 -0
- misterdev/llm/client/edits.py +70 -0
- misterdev/llm/client/embeddings.py +121 -0
- misterdev/llm/client/errors.py +134 -0
- misterdev/llm/client/providers.py +535 -0
- misterdev/llm/client/response.py +24 -0
- misterdev/llm/prompt_manager.py +82 -0
- misterdev/llm/responses/__init__.py +34 -0
- misterdev/llm/responses/apply.py +131 -0
- misterdev/llm/responses/json_extract.py +80 -0
- misterdev/llm/responses/models.py +43 -0
- misterdev/llm/responses/parsing.py +494 -0
- misterdev/logging_setup.py +20 -0
- misterdev/mcp_server.py +208 -0
- misterdev/nl_cli.py +149 -0
- misterdev/plugins.py +115 -0
- misterdev/py.typed +0 -0
- misterdev/task_executors/__init__.py +0 -0
- misterdev/task_executors/base_executor.py +10 -0
- misterdev/task_executors/markdown_plan_executor/__init__.py +90 -0
- misterdev/task_executors/markdown_plan_executor/commands_mixin.py +82 -0
- misterdev/task_executors/markdown_plan_executor/context_mixin.py +221 -0
- misterdev/task_executors/markdown_plan_executor/critic_spec_mixin.py +174 -0
- misterdev/task_executors/markdown_plan_executor/edits_mixin.py +251 -0
- misterdev/task_executors/markdown_plan_executor/execute_mixin.py +727 -0
- misterdev/task_executors/markdown_plan_executor/gates_mixin.py +203 -0
- misterdev/task_executors/markdown_plan_executor/git_mixin.py +219 -0
- misterdev/task_executors/markdown_plan_executor/helpers.py +521 -0
- misterdev/task_executors/markdown_plan_executor/llm_mixin.py +238 -0
- misterdev/task_executors/markdown_plan_executor/results_mixin.py +23 -0
- misterdev/tools/__init__.py +19 -0
- misterdev/tools/base_tool.py +14 -0
- misterdev/tools/command.py +75 -0
- misterdev/tools/file_io.py +69 -0
- misterdev/tools/formatter.py +26 -0
- misterdev/tools/git_tool.py +90 -0
- misterdev/utils/__init__.py +0 -0
- misterdev/utils/file_utils.py +169 -0
- misterdev/utils/process.py +23 -0
- misterdev-0.2.0.dist-info/METADATA +326 -0
- misterdev-0.2.0.dist-info/RECORD +136 -0
- misterdev-0.2.0.dist-info/WHEEL +5 -0
- misterdev-0.2.0.dist-info/entry_points.txt +3 -0
- misterdev-0.2.0.dist-info/licenses/COMMERCIAL_LICENSE.md +34 -0
- misterdev-0.2.0.dist-info/licenses/LICENSE +661 -0
- misterdev-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Symbol data type and its cache (de)serialization."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, List, Set, Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SymbolNode:
|
|
7
|
+
def __init__(
|
|
8
|
+
self,
|
|
9
|
+
name: str,
|
|
10
|
+
file_path: str,
|
|
11
|
+
kind: str,
|
|
12
|
+
start_line: int,
|
|
13
|
+
end_line: int,
|
|
14
|
+
content: str,
|
|
15
|
+
):
|
|
16
|
+
self.name = name
|
|
17
|
+
self.file_path = file_path
|
|
18
|
+
self.kind = kind # 'function', 'class', 'method'
|
|
19
|
+
self.start_line = start_line
|
|
20
|
+
self.end_line = end_line
|
|
21
|
+
self.content = content
|
|
22
|
+
self.outgoing_calls: Set[str] = set()
|
|
23
|
+
self.incoming_calls: Set[str] = set()
|
|
24
|
+
self.imports: List[Dict[str, str]] = [] # {name: ..., module: ...}
|
|
25
|
+
|
|
26
|
+
def __repr__(self):
|
|
27
|
+
return f"Symbol({self.kind}:{self.name} in {self.file_path})"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _symbol_to_dict(s: "SymbolNode") -> Dict[str, Any]:
|
|
31
|
+
"""Serialize the parse-derived fields of a SymbolNode for the disk cache.
|
|
32
|
+
|
|
33
|
+
Only the fields produced by parsing are stored; call neighbors (rebuilt by
|
|
34
|
+
``_resolve_references`` from content) and imports are intentionally omitted
|
|
35
|
+
so the cache never has to track derived/global state.
|
|
36
|
+
"""
|
|
37
|
+
return {
|
|
38
|
+
"name": s.name,
|
|
39
|
+
"file_path": s.file_path,
|
|
40
|
+
"kind": s.kind,
|
|
41
|
+
"start_line": s.start_line,
|
|
42
|
+
"end_line": s.end_line,
|
|
43
|
+
"content": s.content,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _symbol_from_dict(d: Dict[str, Any]) -> "SymbolNode":
|
|
48
|
+
return SymbolNode(
|
|
49
|
+
d["name"],
|
|
50
|
+
d["file_path"],
|
|
51
|
+
d["kind"],
|
|
52
|
+
d["start_line"],
|
|
53
|
+
d["end_line"],
|
|
54
|
+
d["content"],
|
|
55
|
+
)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Tree-sitter parser loading, language detection, and byte-correct slicing."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Any
|
|
4
|
+
|
|
5
|
+
from ._log import logger
|
|
6
|
+
|
|
7
|
+
# Lazy imports for optional heavy dependencies
|
|
8
|
+
_ts_parsers: Dict[str, Any] = {}
|
|
9
|
+
_ts_available = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _get_ts_parsers() -> Dict[str, Any]:
|
|
13
|
+
"""Lazy-load tree-sitter parsers for all available language grammars."""
|
|
14
|
+
global _ts_parsers, _ts_available
|
|
15
|
+
if _ts_available is not None:
|
|
16
|
+
return _ts_parsers
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from tree_sitter import Language, Parser
|
|
20
|
+
except ImportError:
|
|
21
|
+
logger.info("tree-sitter not installed; symbol graph will use basic fallback")
|
|
22
|
+
_ts_available = False
|
|
23
|
+
return _ts_parsers
|
|
24
|
+
|
|
25
|
+
grammars = {
|
|
26
|
+
"python": "tree_sitter_python",
|
|
27
|
+
"rust": "tree_sitter_rust",
|
|
28
|
+
"c": "tree_sitter_c",
|
|
29
|
+
"cpp": "tree_sitter_cpp",
|
|
30
|
+
"swift": "tree_sitter_swift",
|
|
31
|
+
"csharp": "tree_sitter_c_sharp",
|
|
32
|
+
"javascript": "tree_sitter_javascript",
|
|
33
|
+
"kotlin": "tree_sitter_kotlin",
|
|
34
|
+
}
|
|
35
|
+
for lang_name, module_name in grammars.items():
|
|
36
|
+
try:
|
|
37
|
+
mod = __import__(module_name)
|
|
38
|
+
parser = Parser(Language(mod.language()))
|
|
39
|
+
_ts_parsers[lang_name] = parser
|
|
40
|
+
logger.debug(f"tree-sitter {lang_name} grammar loaded")
|
|
41
|
+
except ImportError:
|
|
42
|
+
logger.debug(f"tree-sitter grammar not installed: {module_name}")
|
|
43
|
+
|
|
44
|
+
# TypeScript ships two grammar entrypoints (.ts and .tsx) rather than the
|
|
45
|
+
# single language() the loop above expects, so load it separately.
|
|
46
|
+
try:
|
|
47
|
+
import tree_sitter_typescript as tsts
|
|
48
|
+
|
|
49
|
+
_ts_parsers["typescript"] = Parser(Language(tsts.language_typescript()))
|
|
50
|
+
_ts_parsers["tsx"] = Parser(Language(tsts.language_tsx()))
|
|
51
|
+
logger.debug("tree-sitter typescript grammar loaded")
|
|
52
|
+
except ImportError:
|
|
53
|
+
logger.debug("tree-sitter grammar not installed: tree_sitter_typescript")
|
|
54
|
+
|
|
55
|
+
if _ts_parsers:
|
|
56
|
+
_ts_available = True
|
|
57
|
+
logger.info(f"tree-sitter available for: {', '.join(_ts_parsers)}")
|
|
58
|
+
else:
|
|
59
|
+
_ts_available = False
|
|
60
|
+
logger.info("No tree-sitter grammars installed; symbol graph disabled")
|
|
61
|
+
|
|
62
|
+
return _ts_parsers
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_EXT_TO_LANG: Dict[str, str] = {
|
|
66
|
+
".py": "python",
|
|
67
|
+
".pyi": "python",
|
|
68
|
+
".rs": "rust",
|
|
69
|
+
".ts": "typescript",
|
|
70
|
+
".tsx": "tsx",
|
|
71
|
+
".c": "c",
|
|
72
|
+
".h": "c",
|
|
73
|
+
".cpp": "cpp",
|
|
74
|
+
".cc": "cpp",
|
|
75
|
+
".cxx": "cpp",
|
|
76
|
+
".hpp": "cpp",
|
|
77
|
+
".hh": "cpp",
|
|
78
|
+
".swift": "swift",
|
|
79
|
+
".cs": "csharp",
|
|
80
|
+
".js": "javascript",
|
|
81
|
+
".jsx": "javascript",
|
|
82
|
+
".mjs": "javascript",
|
|
83
|
+
".cjs": "javascript",
|
|
84
|
+
".kt": "kotlin",
|
|
85
|
+
".kts": "kotlin",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _node_text(src: bytes, node: Any) -> str:
|
|
90
|
+
"""Decode a node's span from the UTF-8 source bytes.
|
|
91
|
+
|
|
92
|
+
Offsets from tree-sitter are byte positions, so slicing must happen on the
|
|
93
|
+
bytes and decode after, never on the str.
|
|
94
|
+
"""
|
|
95
|
+
return src[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Tree-sitter parse-based syntax verification used by the correctness gate."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .parsers import _get_ts_parsers
|
|
6
|
+
|
|
7
|
+
# Languages whose tree-sitter grammar is trustworthy enough to gate edits on.
|
|
8
|
+
# Kotlin is excluded: its grammar emits ERROR nodes on some valid code, which
|
|
9
|
+
# would false-reject correct edits. TypeScript is parsed with the TSX grammar
|
|
10
|
+
# (a superset) so JSX never trips a false syntax error.
|
|
11
|
+
_SYNTAX_CHECK_LANGS = {
|
|
12
|
+
"rust",
|
|
13
|
+
"c",
|
|
14
|
+
"cpp",
|
|
15
|
+
"csharp",
|
|
16
|
+
"swift",
|
|
17
|
+
"kotlin",
|
|
18
|
+
"javascript",
|
|
19
|
+
"typescript",
|
|
20
|
+
"tsx",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _first_error_node(node: Any):
|
|
25
|
+
"""Pre-order search for the first ERROR or MISSING node, or None."""
|
|
26
|
+
if node.type == "ERROR" or node.is_missing:
|
|
27
|
+
return node
|
|
28
|
+
for child in node.children:
|
|
29
|
+
found = _first_error_node(child)
|
|
30
|
+
if found is not None:
|
|
31
|
+
return found
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def check_syntax(source: str, lang: str):
|
|
36
|
+
"""Verify ``source`` parses cleanly for ``lang`` using tree-sitter.
|
|
37
|
+
|
|
38
|
+
Returns ``(ok, message)`` when the language has a trustworthy grammar, or
|
|
39
|
+
``None`` when it does not (the caller then falls back to a lighter check).
|
|
40
|
+
Unlike brace-counting, this understands strings and comments, so braces in
|
|
41
|
+
a string literal never false-trip and real syntax errors are caught before
|
|
42
|
+
the expensive build gate.
|
|
43
|
+
"""
|
|
44
|
+
parsers = _get_ts_parsers()
|
|
45
|
+
key = "tsx" if lang in ("typescript", "tsx") else lang
|
|
46
|
+
if lang not in _SYNTAX_CHECK_LANGS or key not in parsers:
|
|
47
|
+
return None
|
|
48
|
+
tree = parsers[key].parse(source.encode("utf-8"))
|
|
49
|
+
err = _first_error_node(tree.root_node)
|
|
50
|
+
if err is None:
|
|
51
|
+
return True, None
|
|
52
|
+
line = err.start_point[0] + 1
|
|
53
|
+
snippet = source.split("\n")[err.start_point[0]].strip()[:80]
|
|
54
|
+
return False, f"{lang} syntax error near line {line}: {snippet}"
|
|
File without changes
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Context budget management for LLM prompts.
|
|
2
|
+
|
|
3
|
+
Estimates token counts and intelligently truncates context sections
|
|
4
|
+
to stay within model limits. By task 15+ in a multi-task build,
|
|
5
|
+
unmanaged context can exceed 100K tokens and degrade LLM quality.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Dict
|
|
9
|
+
from misterdev.logging_setup import setup_logger
|
|
10
|
+
|
|
11
|
+
logger = setup_logger(__name__)
|
|
12
|
+
|
|
13
|
+
# Approximate tokens per character for English/code (conservative). Used only
|
|
14
|
+
# when tiktoken is unavailable.
|
|
15
|
+
CHARS_PER_TOKEN = 3.5
|
|
16
|
+
|
|
17
|
+
_encoder = None
|
|
18
|
+
_encoder_loaded = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _get_encoder():
|
|
22
|
+
"""Lazily load a tiktoken encoder; cache the result (incl. failure)."""
|
|
23
|
+
global _encoder, _encoder_loaded
|
|
24
|
+
if _encoder_loaded:
|
|
25
|
+
return _encoder
|
|
26
|
+
_encoder_loaded = True
|
|
27
|
+
try:
|
|
28
|
+
import tiktoken
|
|
29
|
+
|
|
30
|
+
# cl100k_base is a good general proxy for code/English token counts and
|
|
31
|
+
# avoids a per-model lookup that may miss newer model ids.
|
|
32
|
+
_encoder = tiktoken.get_encoding("cl100k_base")
|
|
33
|
+
except Exception as e: # pragma: no cover - import or offline BPE fetch
|
|
34
|
+
logger.debug(f"tiktoken unavailable; using char heuristic: {e}")
|
|
35
|
+
_encoder = None
|
|
36
|
+
return _encoder
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def estimate_tokens(text: str) -> int:
|
|
40
|
+
"""Count tokens with tiktoken when available, else a char heuristic.
|
|
41
|
+
|
|
42
|
+
Accurate counts keep ContextBudget from over- or under-filling the window
|
|
43
|
+
(the heuristic mis-sizes code, especially the windowed large-file context).
|
|
44
|
+
"""
|
|
45
|
+
if not text:
|
|
46
|
+
return 1
|
|
47
|
+
enc = _get_encoder()
|
|
48
|
+
if enc is not None:
|
|
49
|
+
try:
|
|
50
|
+
return max(1, len(enc.encode(text, disallowed_special=())))
|
|
51
|
+
except Exception as e: # pragma: no cover - encoder edge cases
|
|
52
|
+
# Fall back to the heuristic, but leave a trace: a systematic encoder
|
|
53
|
+
# failure would otherwise silently mis-size every prompt.
|
|
54
|
+
logger.debug(f"tiktoken encode failed; using char heuristic: {e}")
|
|
55
|
+
return max(1, int(len(text) / CHARS_PER_TOKEN))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ContextBudget:
|
|
59
|
+
"""Allocates a token budget across context sections and truncates as needed.
|
|
60
|
+
|
|
61
|
+
Usage:
|
|
62
|
+
budget = ContextBudget(max_tokens=100000)
|
|
63
|
+
budget.set("code_context", big_code_string, priority=1)
|
|
64
|
+
budget.set("scratchpad", scratchpad_text, priority=3)
|
|
65
|
+
budget.set("interface_contracts", contracts, priority=2)
|
|
66
|
+
budget.set("error_logs", errors, priority=1)
|
|
67
|
+
|
|
68
|
+
# Get truncated versions that fit within budget
|
|
69
|
+
sections = budget.allocate()
|
|
70
|
+
code = sections["code_context"]
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, max_tokens: int = 100000, reserved_tokens: int = 8000):
|
|
74
|
+
self.max_tokens = max_tokens
|
|
75
|
+
self.reserved_tokens = reserved_tokens # for system prompt + LLM response
|
|
76
|
+
self.available = max_tokens - reserved_tokens
|
|
77
|
+
self._sections: Dict[str, _Section] = {}
|
|
78
|
+
|
|
79
|
+
def set(self, name: str, content: str, priority: int = 2, min_lines: int = 10):
|
|
80
|
+
"""Register a context section.
|
|
81
|
+
|
|
82
|
+
priority: 1 = essential (truncate last), 2 = important, 3 = nice-to-have (truncate first)
|
|
83
|
+
min_lines: minimum lines to keep even under pressure
|
|
84
|
+
"""
|
|
85
|
+
self._sections[name] = _Section(
|
|
86
|
+
name=name,
|
|
87
|
+
content=content,
|
|
88
|
+
priority=priority,
|
|
89
|
+
min_lines=min_lines,
|
|
90
|
+
tokens=estimate_tokens(content),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def allocate(self) -> Dict[str, str]:
|
|
94
|
+
"""Allocate budget and return truncated sections."""
|
|
95
|
+
total = sum(s.tokens for s in self._sections.values())
|
|
96
|
+
|
|
97
|
+
if total <= self.available:
|
|
98
|
+
return {name: s.content for name, s in self._sections.items()}
|
|
99
|
+
|
|
100
|
+
logger.warning(
|
|
101
|
+
f"Context exceeds budget: {total} tokens > {self.available} available. Truncating."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Sort by priority (highest number = truncate first)
|
|
105
|
+
by_priority = sorted(self._sections.values(), key=lambda s: -s.priority)
|
|
106
|
+
|
|
107
|
+
overflow = total - self.available
|
|
108
|
+
result = {}
|
|
109
|
+
|
|
110
|
+
for section in by_priority:
|
|
111
|
+
if overflow <= 0:
|
|
112
|
+
result[section.name] = section.content
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
# How much can we trim from this section?
|
|
116
|
+
lines = section.content.splitlines()
|
|
117
|
+
if len(lines) <= section.min_lines:
|
|
118
|
+
result[section.name] = section.content
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
# Binary search for the right number of lines
|
|
122
|
+
target_tokens = max(
|
|
123
|
+
estimate_tokens("\n".join(lines[: section.min_lines])),
|
|
124
|
+
section.tokens - overflow,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
kept = _truncate_to_tokens(lines, target_tokens, section.name)
|
|
128
|
+
saved = section.tokens - estimate_tokens(kept)
|
|
129
|
+
overflow -= saved
|
|
130
|
+
result[section.name] = kept
|
|
131
|
+
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
def summary(self) -> str:
|
|
135
|
+
total = sum(s.tokens for s in self._sections.values())
|
|
136
|
+
parts = [f"budget={self.available}"]
|
|
137
|
+
for s in sorted(self._sections.values(), key=lambda s: s.priority):
|
|
138
|
+
parts.append(f"{s.name}={s.tokens}t(p{s.priority})")
|
|
139
|
+
parts.append(f"total={total}")
|
|
140
|
+
over = total > self.available
|
|
141
|
+
parts.append("OVER" if over else "OK")
|
|
142
|
+
return " | ".join(parts)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class _Section:
|
|
146
|
+
def __init__(
|
|
147
|
+
self, name: str, content: str, priority: int, min_lines: int, tokens: int
|
|
148
|
+
):
|
|
149
|
+
self.name = name
|
|
150
|
+
self.content = content
|
|
151
|
+
self.priority = priority
|
|
152
|
+
self.min_lines = min_lines
|
|
153
|
+
self.tokens = tokens
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _truncate_to_tokens(lines: list, target_tokens: int, name: str) -> str:
|
|
157
|
+
"""Keep lines from the start until we hit the target token count."""
|
|
158
|
+
kept = []
|
|
159
|
+
running = 0
|
|
160
|
+
for line in lines:
|
|
161
|
+
line_tokens = estimate_tokens(line)
|
|
162
|
+
if running + line_tokens > target_tokens and len(kept) > 0:
|
|
163
|
+
break
|
|
164
|
+
kept.append(line)
|
|
165
|
+
running += line_tokens
|
|
166
|
+
|
|
167
|
+
total_lines = len(lines)
|
|
168
|
+
omitted = total_lines - len(kept)
|
|
169
|
+
if omitted > 0:
|
|
170
|
+
kept.append(
|
|
171
|
+
f"\n... ({omitted} lines omitted from {name} to fit context budget)"
|
|
172
|
+
)
|
|
173
|
+
logger.info(f"Truncated {name}: kept {len(kept)}/{total_lines} lines")
|
|
174
|
+
|
|
175
|
+
return "\n".join(kept)
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Semantic relevance ranking for context selection.
|
|
2
|
+
|
|
3
|
+
When a task pulls in more candidate code symbols than fit the context budget,
|
|
4
|
+
the symbols were previously truncated by arbitrary order. This ranks candidates
|
|
5
|
+
by cosine similarity of their embedding to the task description and keeps the
|
|
6
|
+
most relevant — the lossless-relevant version of "fit more useful information
|
|
7
|
+
into context".
|
|
8
|
+
|
|
9
|
+
Everything here degrades gracefully: any embedding failure falls back to the
|
|
10
|
+
prior arbitrary-order selection, so semantic ranking can change *which* context
|
|
11
|
+
is shown but never break a build. Embeddings of unchanged code are stable, so
|
|
12
|
+
vectors are cached on disk (keyed by model + text hash) and reused across runs.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import math
|
|
18
|
+
import re
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Dict, List, Optional, Protocol, Set
|
|
21
|
+
|
|
22
|
+
from misterdev.logging_setup import setup_logger
|
|
23
|
+
from misterdev.utils.file_utils import atomic_write_json
|
|
24
|
+
|
|
25
|
+
logger = setup_logger(__name__)
|
|
26
|
+
|
|
27
|
+
# Splits identifiers into subword tokens: snake_case (the separators aren't
|
|
28
|
+
# matched) and camelCase boundaries, plus numbers. Single-char tokens dropped.
|
|
29
|
+
_TOKEN = re.compile(r"[A-Za-z][a-z0-9]*|[A-Z]+(?![a-z])|[0-9]+")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _tokenize(text: str) -> Set[str]:
|
|
33
|
+
return {t.lower() for t in _TOKEN.findall(text or "") if len(t) > 1}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _lexical_overlap(query_tokens: Set[str], text_tokens: Set[str]) -> float:
|
|
37
|
+
"""Fraction of query identifier tokens present in the candidate (0..1).
|
|
38
|
+
|
|
39
|
+
Recall-of-query rather than Jaccard, so a large candidate that contains the
|
|
40
|
+
query's identifiers isn't penalized for its size — the right bias for code,
|
|
41
|
+
where exact identifier matches are strong relevance signals.
|
|
42
|
+
"""
|
|
43
|
+
if not query_tokens:
|
|
44
|
+
return 0.0
|
|
45
|
+
return len(query_tokens & text_tokens) / len(query_tokens)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_EMBEDDING_MODELS_URL = "https://openrouter.ai/api/v1/embeddings/models"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _fetch_embedding_models() -> list:
|
|
52
|
+
from misterdev.core.economics.free_models import _http_fetch
|
|
53
|
+
|
|
54
|
+
return _http_fetch(_EMBEDDING_MODELS_URL)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def pick_embedding_model(
|
|
58
|
+
configured: str, prefer: Optional[List[str]] = None, fetcher=None
|
|
59
|
+
) -> Optional[str]:
|
|
60
|
+
"""The embedding model to use: the explicit config value, else discovered.
|
|
61
|
+
|
|
62
|
+
Mirrors free-model harvesting — discovers OpenRouter's embedding models and
|
|
63
|
+
picks the cheapest (free sorts first). Among equally-priced models, prefers
|
|
64
|
+
ones whose id matches a ``prefer`` hint (default ['code'], since code-aware
|
|
65
|
+
embeddings rank code better); ties broken deterministically by id. Returns
|
|
66
|
+
None if nothing is configured and discovery fails, so semantic retrieval
|
|
67
|
+
stays off gracefully rather than guessing a model.
|
|
68
|
+
"""
|
|
69
|
+
if configured:
|
|
70
|
+
return configured
|
|
71
|
+
hints = [h.lower() for h in (prefer if prefer is not None else ["code"])]
|
|
72
|
+
try:
|
|
73
|
+
models = (fetcher or _fetch_embedding_models)()
|
|
74
|
+
except Exception as e:
|
|
75
|
+
logger.warning(f"Embedding-model discovery failed: {e}")
|
|
76
|
+
return None
|
|
77
|
+
ranked = []
|
|
78
|
+
for m in models:
|
|
79
|
+
if not isinstance(m, dict) or not m.get("id"):
|
|
80
|
+
continue
|
|
81
|
+
pricing = m.get("pricing") or {}
|
|
82
|
+
try:
|
|
83
|
+
price = float(pricing.get("prompt", "inf"))
|
|
84
|
+
except (TypeError, ValueError):
|
|
85
|
+
price = float("inf")
|
|
86
|
+
model_id = m["id"]
|
|
87
|
+
preferred = 0 if any(h in model_id.lower() for h in hints) else 1
|
|
88
|
+
ranked.append((price, preferred, model_id))
|
|
89
|
+
if not ranked:
|
|
90
|
+
return None
|
|
91
|
+
ranked.sort(key=lambda r: (r[0], r[1], r[2]))
|
|
92
|
+
logger.info(f"Auto-selected embedding model {ranked[0][2]!r}")
|
|
93
|
+
return ranked[0][2]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def cosine_similarity(a: List[float], b: List[float]) -> float:
|
|
97
|
+
"""Cosine similarity of two equal-length vectors (0.0 on degenerate input)."""
|
|
98
|
+
if not a or not b or len(a) != len(b):
|
|
99
|
+
return 0.0
|
|
100
|
+
dot = math.fsum(x * y for x, y in zip(a, b))
|
|
101
|
+
na = math.sqrt(math.fsum(x * x for x in a))
|
|
102
|
+
nb = math.sqrt(math.fsum(y * y for y in b))
|
|
103
|
+
if na == 0.0 or nb == 0.0:
|
|
104
|
+
return 0.0
|
|
105
|
+
return dot / (na * nb)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Embedder(Protocol):
|
|
109
|
+
"""Minimal interface a semantic ranker needs from an embedding client."""
|
|
110
|
+
|
|
111
|
+
model: str
|
|
112
|
+
|
|
113
|
+
def embed(self, texts: List[str]) -> List[List[float]]: ...
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class EmbeddingCache:
|
|
117
|
+
"""Disk-backed cache of text -> vector, keyed by model and content hash."""
|
|
118
|
+
|
|
119
|
+
def __init__(self, path: Path, model: str):
|
|
120
|
+
self.path = Path(path)
|
|
121
|
+
self.model = model
|
|
122
|
+
self._vectors: Optional[Dict[str, List[float]]] = None
|
|
123
|
+
|
|
124
|
+
def _key(self, text: str) -> str:
|
|
125
|
+
h = hashlib.sha256()
|
|
126
|
+
h.update(f"{self.model}\x00".encode("utf-8"))
|
|
127
|
+
h.update(text.encode("utf-8"))
|
|
128
|
+
return h.hexdigest()
|
|
129
|
+
|
|
130
|
+
def _load(self) -> Dict[str, List[float]]:
|
|
131
|
+
if self._vectors is None:
|
|
132
|
+
self._vectors = {}
|
|
133
|
+
if self.path.exists():
|
|
134
|
+
try:
|
|
135
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
136
|
+
if isinstance(data, dict):
|
|
137
|
+
self._vectors = data
|
|
138
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
139
|
+
logger.warning(f"Embedding cache unreadable, starting fresh: {e}")
|
|
140
|
+
return self._vectors
|
|
141
|
+
|
|
142
|
+
def get(self, text: str) -> Optional[List[float]]:
|
|
143
|
+
return self._load().get(self._key(text))
|
|
144
|
+
|
|
145
|
+
def put_many(self, items: Dict[str, List[float]]) -> None:
|
|
146
|
+
"""Store {text: vector} and persist."""
|
|
147
|
+
store = self._load()
|
|
148
|
+
for text, vector in items.items():
|
|
149
|
+
store[self._key(text)] = vector
|
|
150
|
+
atomic_write_json(self.path, store)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class SemanticRanker:
|
|
154
|
+
"""Selects the top-k most task-relevant items via a hybrid relevance score.
|
|
155
|
+
|
|
156
|
+
Combines dense embedding cosine with a lexical identifier-overlap signal —
|
|
157
|
+
code relevance is driven heavily by exact identifier matches, which dense
|
|
158
|
+
similarity alone can miss. The embedder is optional: with none (or on an
|
|
159
|
+
embedding failure) the ranker degrades to lexical-only, which still beats the
|
|
160
|
+
arbitrary-order slice it replaces.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def __init__(
|
|
164
|
+
self,
|
|
165
|
+
embedder: Optional[Embedder] = None,
|
|
166
|
+
cache: Optional[EmbeddingCache] = None,
|
|
167
|
+
lexical_weight: float = 0.3,
|
|
168
|
+
):
|
|
169
|
+
self.embedder = embedder
|
|
170
|
+
self.cache = cache
|
|
171
|
+
self.lexical_weight = max(0.0, min(1.0, lexical_weight))
|
|
172
|
+
|
|
173
|
+
def _embed_with_cache(self, texts: List[str]) -> Optional[List[List[float]]]:
|
|
174
|
+
"""Embed texts, serving cache hits and embedding only the misses."""
|
|
175
|
+
cached: Dict[str, Optional[List[float]]] = {}
|
|
176
|
+
misses: List[str] = []
|
|
177
|
+
for text in texts:
|
|
178
|
+
vec = self.cache.get(text) if self.cache else None
|
|
179
|
+
cached[text] = vec
|
|
180
|
+
if vec is None and text not in misses:
|
|
181
|
+
misses.append(text)
|
|
182
|
+
if misses:
|
|
183
|
+
fresh = self.embedder.embed(misses)
|
|
184
|
+
if len(fresh) != len(misses):
|
|
185
|
+
raise ValueError("embedding count mismatch")
|
|
186
|
+
new = dict(zip(misses, fresh))
|
|
187
|
+
if self.cache:
|
|
188
|
+
self.cache.put_many(new)
|
|
189
|
+
for text, vec in new.items():
|
|
190
|
+
cached[text] = vec
|
|
191
|
+
return [cached[text] for text in texts]
|
|
192
|
+
|
|
193
|
+
def top_k(self, query: str, candidates: Dict[str, str], k: int) -> List[str]:
|
|
194
|
+
"""Return up to k candidate ids most relevant to query.
|
|
195
|
+
|
|
196
|
+
candidates maps id -> text. Scores blend dense cosine (when an embedder
|
|
197
|
+
is available) with lexical identifier overlap; on embedding failure it
|
|
198
|
+
uses lexical alone. Never raises — ranking is an optimization under the
|
|
199
|
+
validation gates, not load-bearing for correctness.
|
|
200
|
+
"""
|
|
201
|
+
ids = list(candidates)
|
|
202
|
+
if k >= len(ids):
|
|
203
|
+
return ids
|
|
204
|
+
|
|
205
|
+
query_tokens = _tokenize(query)
|
|
206
|
+
lexical = {
|
|
207
|
+
i: _lexical_overlap(query_tokens, _tokenize(candidates[i])) for i in ids
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
dense: Optional[Dict[str, float]] = None
|
|
211
|
+
if self.embedder is not None:
|
|
212
|
+
try:
|
|
213
|
+
vectors = self._embed_with_cache([query] + [candidates[i] for i in ids])
|
|
214
|
+
query_vec = vectors[0]
|
|
215
|
+
dense = {
|
|
216
|
+
i: (cosine_similarity(query_vec, v) + 1.0) / 2.0
|
|
217
|
+
for i, v in zip(ids, vectors[1:])
|
|
218
|
+
}
|
|
219
|
+
except Exception as e:
|
|
220
|
+
logger.warning(f"Dense ranking unavailable, using lexical only: {e}")
|
|
221
|
+
|
|
222
|
+
if dense is None:
|
|
223
|
+
|
|
224
|
+
def score(i: str) -> float:
|
|
225
|
+
return lexical[i]
|
|
226
|
+
else:
|
|
227
|
+
w = self.lexical_weight
|
|
228
|
+
|
|
229
|
+
def score(i: str) -> float:
|
|
230
|
+
return (1.0 - w) * dense[i] + w * lexical[i]
|
|
231
|
+
|
|
232
|
+
return sorted(ids, key=score, reverse=True)[:k]
|