synap-git 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.
- synap_git/__init__.py +5 -0
- synap_git/api/__init__.py +5 -0
- synap_git/api/app.py +111 -0
- synap_git/api/static/index.html +615 -0
- synap_git/cli/__init__.py +3 -0
- synap_git/cli/__main__.py +4 -0
- synap_git/cli/main.py +1174 -0
- synap_git/config.py +169 -0
- synap_git/diagnostics/__init__.py +0 -0
- synap_git/diagnostics/logger.py +82 -0
- synap_git/diagnostics/tracing.py +71 -0
- synap_git/embeddings/__init__.py +0 -0
- synap_git/git/__init__.py +11 -0
- synap_git/git/state.py +139 -0
- synap_git/indexer/__init__.py +0 -0
- synap_git/indexer/daemon.py +143 -0
- synap_git/indexer/engine.py +327 -0
- synap_git/indexer/scanner.py +463 -0
- synap_git/indexer/wiki.py +78 -0
- synap_git/mcp/__init__.py +5 -0
- synap_git/mcp/server.py +232 -0
- synap_git/parser/__init__.py +0 -0
- synap_git/parser/registry.py +209 -0
- synap_git/provider/anthropic.py +86 -0
- synap_git/provider/base.py +44 -0
- synap_git/provider/factory.py +101 -0
- synap_git/provider/gemini.py +98 -0
- synap_git/provider/mock.py +66 -0
- synap_git/provider/ollama.py +98 -0
- synap_git/provider/openai.py +90 -0
- synap_git/py.typed +1 -0
- synap_git/retrieval/__init__.py +0 -0
- synap_git/retrieval/engine.py +258 -0
- synap_git/retrieval/memory.py +123 -0
- synap_git/storage/__init__.py +3 -0
- synap_git/storage/sqlite.py +575 -0
- synap_git/utils/__init__.py +0 -0
- synap_git/utils/serialization.py +68 -0
- synap_git-0.2.0.dist-info/METADATA +251 -0
- synap_git-0.2.0.dist-info/RECORD +43 -0
- synap_git-0.2.0.dist-info/WHEEL +4 -0
- synap_git-0.2.0.dist-info/entry_points.txt +2 -0
- synap_git-0.2.0.dist-info/licenses/LICENSE.md +201 -0
synap_git/mcp/server.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import inspect
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from mcp.server.fastmcp import FastMCP
|
|
11
|
+
|
|
12
|
+
from synap_git.indexer.engine import SynapRuntime
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SynapMCPFacade:
|
|
16
|
+
"""Model Context Protocol facade over the simplified Synap runtime."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, runtime: SynapRuntime) -> None:
|
|
19
|
+
self.runtime = runtime
|
|
20
|
+
|
|
21
|
+
def get_status(self) -> dict[str, Any]:
|
|
22
|
+
status = self.runtime.status()
|
|
23
|
+
return status.__dict__
|
|
24
|
+
|
|
25
|
+
def search(self, query: str, max_tokens: int = 4000) -> dict[str, Any]:
|
|
26
|
+
result, ctx, debug = self.runtime.query_hybrid(query, max_tokens=max_tokens)
|
|
27
|
+
return {"result": result, "context": ctx, "trace": debug}
|
|
28
|
+
|
|
29
|
+
def create_checkpoint(
|
|
30
|
+
self, doing: str, changed_files: list[str], next_step: str, blockers: str
|
|
31
|
+
) -> dict[str, Any]:
|
|
32
|
+
status = self.runtime.status()
|
|
33
|
+
checkpoint_id = str(uuid.uuid4())
|
|
34
|
+
branch = status.branch
|
|
35
|
+
commit = status.git_commit or "unknown"
|
|
36
|
+
self.runtime.store.put_checkpoint(
|
|
37
|
+
checkpoint_id, branch, commit, doing, json.dumps(changed_files), next_step, blockers
|
|
38
|
+
)
|
|
39
|
+
return {"status": "success", "checkpoint_id": checkpoint_id}
|
|
40
|
+
|
|
41
|
+
def restore_checkpoint(self, checkpoint_id: str) -> dict[str, Any]:
|
|
42
|
+
status = self.runtime.status()
|
|
43
|
+
cp = self.runtime.store.get_latest_checkpoint(status.branch)
|
|
44
|
+
if not cp:
|
|
45
|
+
raise ValueError("No checkpoint found for current branch.")
|
|
46
|
+
return dict(cp)
|
|
47
|
+
|
|
48
|
+
def log_decision(self, content: str, context_info: str) -> dict[str, Any]:
|
|
49
|
+
status = self.runtime.status()
|
|
50
|
+
decision_id = str(uuid.uuid4())
|
|
51
|
+
branch = status.branch
|
|
52
|
+
commit = status.git_commit or "unknown"
|
|
53
|
+
self.runtime.store.put_decision(decision_id, branch, commit, content, context_info)
|
|
54
|
+
return {"status": "success", "decision_id": decision_id}
|
|
55
|
+
|
|
56
|
+
def verify_system(self) -> dict[str, Any]:
|
|
57
|
+
res = self.runtime.doctor()
|
|
58
|
+
return res
|
|
59
|
+
|
|
60
|
+
def submit_lesson_analysis(self, lesson_id: str, why_failed: str) -> dict[str, Any]:
|
|
61
|
+
self.runtime.store.update_lesson(lesson_id, why_failed, "pending", actor="mcp_agent")
|
|
62
|
+
return {"status": "success", "message": "Lesson awaiting human approval"}
|
|
63
|
+
|
|
64
|
+
def get_approved_memory(self) -> dict[str, Any]:
|
|
65
|
+
lessons = self.runtime.store.get_lessons("approved")
|
|
66
|
+
return {"status": "success", "lessons": lessons}
|
|
67
|
+
|
|
68
|
+
def get_pending_memory(self) -> dict[str, Any]:
|
|
69
|
+
lessons = self.runtime.store.get_lessons("pending")
|
|
70
|
+
return {"status": "success", "lessons": lessons}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class SynapMCPServer:
|
|
74
|
+
"""Exposes Synap tools to AI agents via MCP Stdio."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, runtime: SynapRuntime) -> None:
|
|
77
|
+
self.mcp = FastMCP("Synap Context Runtime")
|
|
78
|
+
self.facade = SynapMCPFacade(runtime)
|
|
79
|
+
self._register_tools()
|
|
80
|
+
|
|
81
|
+
def _register_tools(self) -> None:
|
|
82
|
+
from synap_git.diagnostics.logger import get_logger
|
|
83
|
+
|
|
84
|
+
logger = get_logger("mcp_server")
|
|
85
|
+
|
|
86
|
+
from collections.abc import Callable
|
|
87
|
+
|
|
88
|
+
def _wrap(f: Callable[..., dict[str, Any]]) -> Callable[..., str]:
|
|
89
|
+
@functools.wraps(f)
|
|
90
|
+
def wrapper(*args: Any, **kwargs: Any) -> str:
|
|
91
|
+
trace_id = str(uuid.uuid4())
|
|
92
|
+
try:
|
|
93
|
+
status = self.facade.runtime.status()
|
|
94
|
+
dirty = status.is_dirty
|
|
95
|
+
warnings = []
|
|
96
|
+
if dirty:
|
|
97
|
+
warnings.append("Working tree is dirty. Index may be stale.")
|
|
98
|
+
|
|
99
|
+
start = time.monotonic()
|
|
100
|
+
data = f(*args, **kwargs)
|
|
101
|
+
duration_ms = (time.monotonic() - start) * 1000
|
|
102
|
+
logger.info(
|
|
103
|
+
"mcp_tool_invoked",
|
|
104
|
+
tool=f.__name__,
|
|
105
|
+
latency_ms=duration_ms,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
self.facade.runtime.trace_store.record_trace(
|
|
110
|
+
trace_type="mcp",
|
|
111
|
+
summary=f"MCP Tool Invoked: {f.__name__}",
|
|
112
|
+
details={
|
|
113
|
+
"tool": f.__name__,
|
|
114
|
+
"arguments": kwargs,
|
|
115
|
+
"duration_ms": duration_ms,
|
|
116
|
+
"success": True,
|
|
117
|
+
"trace_id": trace_id,
|
|
118
|
+
"dirty_tree": dirty,
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
return json.dumps(
|
|
125
|
+
{
|
|
126
|
+
"ok": True,
|
|
127
|
+
"data": data,
|
|
128
|
+
"warnings": warnings,
|
|
129
|
+
"trace_id": trace_id,
|
|
130
|
+
"dirty_tree": dirty,
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
except Exception as e:
|
|
134
|
+
msg = str(e)
|
|
135
|
+
code = "INTERNAL_ERROR"
|
|
136
|
+
suggestion = "Check daemon logs."
|
|
137
|
+
|
|
138
|
+
if "stale" in msg.lower():
|
|
139
|
+
code = "INDEX_STALE"
|
|
140
|
+
suggestion = "Run `synap reindex` or wait for daemon."
|
|
141
|
+
elif "no checkpoint" in msg.lower():
|
|
142
|
+
code = "NOT_FOUND"
|
|
143
|
+
suggestion = "Ensure checkpoints exist for this branch."
|
|
144
|
+
|
|
145
|
+
logger.error("mcp_tool_error", tool=f.__name__, error=msg)
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
self.facade.runtime.trace_store.record_trace(
|
|
149
|
+
trace_type="mcp",
|
|
150
|
+
summary=f"MCP Tool Failed: {f.__name__}",
|
|
151
|
+
details={
|
|
152
|
+
"tool": f.__name__,
|
|
153
|
+
"arguments": kwargs,
|
|
154
|
+
"error": msg,
|
|
155
|
+
"success": False,
|
|
156
|
+
"trace_id": trace_id,
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
return json.dumps(
|
|
163
|
+
{
|
|
164
|
+
"ok": False,
|
|
165
|
+
"error": {"code": code, "message": msg, "suggestion": suggestion},
|
|
166
|
+
"warnings": [],
|
|
167
|
+
"trace_id": trace_id,
|
|
168
|
+
"dirty_tree": False,
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
wrapper.__signature__ = inspect.signature(f) # type: ignore
|
|
173
|
+
return wrapper
|
|
174
|
+
|
|
175
|
+
@self.mcp.tool()
|
|
176
|
+
@_wrap
|
|
177
|
+
def get_status() -> dict[str, Any]:
|
|
178
|
+
"""Get current repository indexing status and active commit."""
|
|
179
|
+
return self.facade.get_status()
|
|
180
|
+
|
|
181
|
+
@self.mcp.tool()
|
|
182
|
+
@_wrap
|
|
183
|
+
def search(query: str, max_tokens: int = 4000) -> dict[str, Any]:
|
|
184
|
+
"""Search the repository context for semantic and structural matches."""
|
|
185
|
+
return self.facade.search(query, max_tokens)
|
|
186
|
+
|
|
187
|
+
@self.mcp.tool()
|
|
188
|
+
@_wrap
|
|
189
|
+
def create_checkpoint(
|
|
190
|
+
doing: str, changed_files: list[str], next_step: str, blockers: str
|
|
191
|
+
) -> dict[str, Any]:
|
|
192
|
+
"""Save the current agent's thought process and state as a checkpoint."""
|
|
193
|
+
return self.facade.create_checkpoint(doing, changed_files, next_step, blockers)
|
|
194
|
+
|
|
195
|
+
@self.mcp.tool()
|
|
196
|
+
@_wrap
|
|
197
|
+
def restore_checkpoint() -> dict[str, Any]:
|
|
198
|
+
"""Restore the latest checkpoint for the current branch to resume context."""
|
|
199
|
+
return self.facade.restore_checkpoint("latest")
|
|
200
|
+
|
|
201
|
+
@self.mcp.tool()
|
|
202
|
+
@_wrap
|
|
203
|
+
def log_decision(content: str, context_info: str) -> dict[str, Any]:
|
|
204
|
+
"""Log an architectural or technical decision to the project's decision log."""
|
|
205
|
+
return self.facade.log_decision(content, context_info)
|
|
206
|
+
|
|
207
|
+
@self.mcp.tool()
|
|
208
|
+
@_wrap
|
|
209
|
+
def verify_system() -> dict[str, Any]:
|
|
210
|
+
"""Verify database integrity and run internal diagnostics."""
|
|
211
|
+
return self.facade.verify_system()
|
|
212
|
+
|
|
213
|
+
@self.mcp.tool()
|
|
214
|
+
@_wrap
|
|
215
|
+
def submit_lesson_analysis(lesson_id: str, why_failed: str) -> dict[str, Any]:
|
|
216
|
+
"""Submit an analysis for a pending lesson (e.g., after a revert). Lesson remains pending until human approval."""
|
|
217
|
+
return self.facade.submit_lesson_analysis(lesson_id, why_failed)
|
|
218
|
+
|
|
219
|
+
@self.mcp.tool()
|
|
220
|
+
@_wrap
|
|
221
|
+
def get_approved_memory() -> dict[str, Any]:
|
|
222
|
+
"""Retrieve APPROVED memory lessons that MUST be strictly adhered to."""
|
|
223
|
+
return self.facade.get_approved_memory()
|
|
224
|
+
|
|
225
|
+
@self.mcp.tool()
|
|
226
|
+
@_wrap
|
|
227
|
+
def get_pending_memory() -> dict[str, Any]:
|
|
228
|
+
"""Retrieve PENDING memory lessons currently awaiting human review."""
|
|
229
|
+
return self.facade.get_pending_memory()
|
|
230
|
+
|
|
231
|
+
async def run(self) -> None:
|
|
232
|
+
await self.mcp.run_stdio_async()
|
|
File without changes
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import tree_sitter_languages # type: ignore
|
|
8
|
+
from tree_sitter import Node, Parser
|
|
9
|
+
|
|
10
|
+
from synap_git.utils.serialization import stable_hash
|
|
11
|
+
|
|
12
|
+
PYTHON_SUFFIXES = {".py"}
|
|
13
|
+
JAVASCRIPT_SUFFIXES = {".js", ".jsx"}
|
|
14
|
+
TYPESCRIPT_SUFFIXES = {".ts", ".tsx"}
|
|
15
|
+
GO_SUFFIXES = {".go"}
|
|
16
|
+
RUST_SUFFIXES = {".rs"}
|
|
17
|
+
JAVA_SUFFIXES = {".java"}
|
|
18
|
+
CPP_SUFFIXES = {".cpp", ".cc", ".cxx", ".hpp", ".h"}
|
|
19
|
+
RUBY_SUFFIXES = {".rb"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class CodeSymbol:
|
|
24
|
+
stable_id: str
|
|
25
|
+
kind: str
|
|
26
|
+
name: str
|
|
27
|
+
source_path: str
|
|
28
|
+
start_line: int
|
|
29
|
+
end_line: int
|
|
30
|
+
ast_hash: str
|
|
31
|
+
metadata: dict[str, Any] | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class CodeParseResult:
|
|
36
|
+
path: str
|
|
37
|
+
language: str
|
|
38
|
+
symbols: tuple[CodeSymbol, ...]
|
|
39
|
+
imports: tuple[str, ...] = ()
|
|
40
|
+
syntax_error: str | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CodeParserRegistry:
|
|
44
|
+
"""Deterministic AST parser using Tree-sitter for structural grounding."""
|
|
45
|
+
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
self._parsers: dict[str, Parser] = {}
|
|
48
|
+
|
|
49
|
+
def _get_parser(self, lang_name: str) -> Parser:
|
|
50
|
+
if lang_name not in self._parsers:
|
|
51
|
+
lang = tree_sitter_languages.get_language(lang_name)
|
|
52
|
+
parser = Parser()
|
|
53
|
+
parser.set_language(lang)
|
|
54
|
+
self._parsers[lang_name] = parser
|
|
55
|
+
return self._parsers[lang_name]
|
|
56
|
+
|
|
57
|
+
def parse(self, path: Path, *, relative_path: str) -> CodeParseResult:
|
|
58
|
+
suffix = path.suffix.lower()
|
|
59
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
60
|
+
|
|
61
|
+
if suffix in PYTHON_SUFFIXES:
|
|
62
|
+
return self._parse_tree_sitter(text, "python", relative_path)
|
|
63
|
+
if suffix in JAVASCRIPT_SUFFIXES:
|
|
64
|
+
return self._parse_tree_sitter(text, "javascript", relative_path)
|
|
65
|
+
if suffix in TYPESCRIPT_SUFFIXES:
|
|
66
|
+
return self._parse_tree_sitter(text, "tsx", relative_path)
|
|
67
|
+
if suffix in GO_SUFFIXES:
|
|
68
|
+
return self._parse_tree_sitter(text, "go", relative_path)
|
|
69
|
+
if suffix in RUST_SUFFIXES:
|
|
70
|
+
return self._parse_tree_sitter(text, "rust", relative_path)
|
|
71
|
+
if suffix in JAVA_SUFFIXES:
|
|
72
|
+
return self._parse_tree_sitter(text, "java", relative_path)
|
|
73
|
+
if suffix in CPP_SUFFIXES:
|
|
74
|
+
return self._parse_tree_sitter(text, "cpp", relative_path)
|
|
75
|
+
if suffix in RUBY_SUFFIXES:
|
|
76
|
+
return self._parse_tree_sitter(text, "ruby", relative_path)
|
|
77
|
+
|
|
78
|
+
return CodeParseResult(path=relative_path, language="unknown", symbols=())
|
|
79
|
+
|
|
80
|
+
def _parse_tree_sitter(self, text: str, lang_name: str, relative_path: str) -> CodeParseResult:
|
|
81
|
+
parser = self._get_parser(lang_name)
|
|
82
|
+
text_bytes = bytes(text, "utf8")
|
|
83
|
+
tree = parser.parse(text_bytes)
|
|
84
|
+
|
|
85
|
+
symbols: list[CodeSymbol] = []
|
|
86
|
+
imports: list[str] = []
|
|
87
|
+
|
|
88
|
+
def extract_text(node: Node | None) -> str | None:
|
|
89
|
+
if not node:
|
|
90
|
+
return None
|
|
91
|
+
return text_bytes[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
|
|
92
|
+
|
|
93
|
+
# Simple recursive traversal to extract symbols
|
|
94
|
+
def traverse(node: Node) -> None:
|
|
95
|
+
kind = node.type
|
|
96
|
+
name = None
|
|
97
|
+
|
|
98
|
+
# Basic symbol extraction logic per language
|
|
99
|
+
if lang_name == "python":
|
|
100
|
+
if kind in ("class_definition", "function_definition"):
|
|
101
|
+
name = extract_text(node.child_by_field_name("name"))
|
|
102
|
+
elif kind == "import_from_statement":
|
|
103
|
+
mod_name = extract_text(node.child_by_field_name("module_name"))
|
|
104
|
+
if mod_name:
|
|
105
|
+
imports.append(mod_name)
|
|
106
|
+
elif kind == "import_statement":
|
|
107
|
+
for child in node.children:
|
|
108
|
+
if child.type == "dotted_name":
|
|
109
|
+
imp_name = extract_text(child)
|
|
110
|
+
if imp_name:
|
|
111
|
+
imports.append(imp_name)
|
|
112
|
+
|
|
113
|
+
elif lang_name in ("javascript", "tsx"):
|
|
114
|
+
# Capturing classes, functions, methods, interfaces, and types
|
|
115
|
+
if kind in (
|
|
116
|
+
"class_declaration",
|
|
117
|
+
"function_declaration",
|
|
118
|
+
"method_definition",
|
|
119
|
+
"interface_declaration",
|
|
120
|
+
"type_alias_declaration",
|
|
121
|
+
"class",
|
|
122
|
+
):
|
|
123
|
+
name = extract_text(node.child_by_field_name("name"))
|
|
124
|
+
elif kind == "variable_declarator":
|
|
125
|
+
# Catch const foo = () => ...
|
|
126
|
+
value_node = node.child_by_field_name("value")
|
|
127
|
+
if value_node and value_node.type in ("arrow_function", "function_expression"):
|
|
128
|
+
name = extract_text(node.child_by_field_name("name"))
|
|
129
|
+
if name:
|
|
130
|
+
kind = "function_definition"
|
|
131
|
+
|
|
132
|
+
elif kind == "import_statement":
|
|
133
|
+
src = extract_text(node.child_by_field_name("source"))
|
|
134
|
+
if src:
|
|
135
|
+
imports.append(src.strip("'\""))
|
|
136
|
+
|
|
137
|
+
elif lang_name == "go":
|
|
138
|
+
if kind in ("function_declaration", "method_declaration", "type_spec"):
|
|
139
|
+
name = extract_text(node.child_by_field_name("name"))
|
|
140
|
+
elif kind == "import_spec":
|
|
141
|
+
path_val = extract_text(node.child_by_field_name("path"))
|
|
142
|
+
if path_val:
|
|
143
|
+
imports.append(path_val.strip('"'))
|
|
144
|
+
|
|
145
|
+
elif lang_name == "rust":
|
|
146
|
+
if kind in ("function_item", "struct_item", "enum_item", "trait_item", "impl_item"):
|
|
147
|
+
name = extract_text(node.child_by_field_name("name"))
|
|
148
|
+
if not name and kind == "impl_item":
|
|
149
|
+
t_name = extract_text(node.child_by_field_name("type"))
|
|
150
|
+
if t_name:
|
|
151
|
+
name = "impl " + t_name
|
|
152
|
+
|
|
153
|
+
elif lang_name == "java":
|
|
154
|
+
if kind in (
|
|
155
|
+
"class_declaration",
|
|
156
|
+
"method_declaration",
|
|
157
|
+
"interface_declaration",
|
|
158
|
+
"enum_declaration",
|
|
159
|
+
):
|
|
160
|
+
name = extract_text(node.child_by_field_name("name"))
|
|
161
|
+
|
|
162
|
+
elif lang_name == "cpp":
|
|
163
|
+
if kind in ("function_definition", "class_specifier", "struct_specifier"):
|
|
164
|
+
name = extract_text(node.child_by_field_name("name"))
|
|
165
|
+
if not name:
|
|
166
|
+
# Fallback to declarator
|
|
167
|
+
declarator = node.child_by_field_name("declarator")
|
|
168
|
+
if declarator:
|
|
169
|
+
name = extract_text(declarator.child_by_field_name("declarator"))
|
|
170
|
+
|
|
171
|
+
elif lang_name == "ruby":
|
|
172
|
+
if kind in ("class", "module", "method"):
|
|
173
|
+
name = extract_text(node.child_by_field_name("name"))
|
|
174
|
+
|
|
175
|
+
if name:
|
|
176
|
+
# Compute hash of the node's subtree for change detection
|
|
177
|
+
ast_content = extract_text(node) or ""
|
|
178
|
+
ast_hash = stable_hash(ast_content)
|
|
179
|
+
|
|
180
|
+
# Ensure uniqueness by appending start/end lines if needed, but for now we rely on name/kind
|
|
181
|
+
# since stable_id uses path, name, kind. Wait, if multiple methods have same name (e.g. overloads)
|
|
182
|
+
# it will duplicate. Let's include start_line in stable_id to prevent IntegrityError!
|
|
183
|
+
sym_id = stable_hash(
|
|
184
|
+
{"path": relative_path, "name": name, "kind": kind, "line": node.start_point[0]}
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
symbols.append(
|
|
188
|
+
CodeSymbol(
|
|
189
|
+
stable_id=sym_id,
|
|
190
|
+
kind=kind,
|
|
191
|
+
name=name,
|
|
192
|
+
source_path=relative_path,
|
|
193
|
+
start_line=node.start_point[0] + 1,
|
|
194
|
+
end_line=node.end_point[0] + 1,
|
|
195
|
+
ast_hash=ast_hash,
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
for child in node.children:
|
|
200
|
+
traverse(child)
|
|
201
|
+
|
|
202
|
+
traverse(tree.root_node)
|
|
203
|
+
|
|
204
|
+
return CodeParseResult(
|
|
205
|
+
path=relative_path,
|
|
206
|
+
language=lang_name,
|
|
207
|
+
symbols=tuple(symbols),
|
|
208
|
+
imports=tuple(sorted(set(imports))),
|
|
209
|
+
)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import urllib.request
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from synap_git.provider.base import LLMProvider, LLMResponse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AnthropicProvider(LLMProvider):
|
|
11
|
+
"""Anthropic API provider implementation."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, api_key: str, default_model: str = "claude-3-5-sonnet-latest") -> None:
|
|
14
|
+
self.api_key = api_key
|
|
15
|
+
self.default_model = default_model
|
|
16
|
+
|
|
17
|
+
def generate(
|
|
18
|
+
self,
|
|
19
|
+
system_prompt: str,
|
|
20
|
+
user_prompt: str,
|
|
21
|
+
*,
|
|
22
|
+
model: str | None = None,
|
|
23
|
+
max_tokens: int | None = None,
|
|
24
|
+
temperature: float = 0.2,
|
|
25
|
+
) -> LLMResponse:
|
|
26
|
+
model_name = model or self.default_model
|
|
27
|
+
url = "https://api.anthropic.com/v1/messages"
|
|
28
|
+
headers = {
|
|
29
|
+
"Content-Type": "application/json",
|
|
30
|
+
"x-api-key": self.api_key,
|
|
31
|
+
"anthropic-version": "2023-06-01",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
payload: dict[str, Any] = {
|
|
35
|
+
"model": model_name,
|
|
36
|
+
"system": system_prompt,
|
|
37
|
+
"messages": [
|
|
38
|
+
{"role": "user", "content": user_prompt},
|
|
39
|
+
],
|
|
40
|
+
"temperature": temperature,
|
|
41
|
+
"max_tokens": max_tokens or 4096,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
req = urllib.request.Request(
|
|
45
|
+
url, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST"
|
|
46
|
+
)
|
|
47
|
+
try:
|
|
48
|
+
with urllib.request.urlopen(req, timeout=45.0) as resp: # nosec B310
|
|
49
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
50
|
+
|
|
51
|
+
content = data["content"][0]["text"]
|
|
52
|
+
usage = data.get("usage", {})
|
|
53
|
+
prompt_tokens = usage.get("input_tokens", 0)
|
|
54
|
+
completion_tokens = usage.get("output_tokens", 0)
|
|
55
|
+
cached_tokens = usage.get("cache_creation_input_tokens", 0) + usage.get(
|
|
56
|
+
"cache_read_input_tokens", 0
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return LLMResponse(
|
|
60
|
+
content=content,
|
|
61
|
+
prompt_tokens=prompt_tokens,
|
|
62
|
+
completion_tokens=completion_tokens,
|
|
63
|
+
cached_tokens=cached_tokens,
|
|
64
|
+
)
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
raise RuntimeError(f"Anthropic generate failed: {exc}") from exc
|
|
67
|
+
|
|
68
|
+
def embed(
|
|
69
|
+
self,
|
|
70
|
+
text: str,
|
|
71
|
+
*,
|
|
72
|
+
model: str | None = None,
|
|
73
|
+
) -> list[float]:
|
|
74
|
+
# Anthropic does not have a public embedding endpoint as of mid 2024.
|
|
75
|
+
# Typically developers use Voyage or OpenAI for embeddings when using Anthropic.
|
|
76
|
+
# We will throw a clear error.
|
|
77
|
+
raise NotImplementedError(
|
|
78
|
+
"Anthropic does not provide a native embeddings API. Please configure SYNAP_EMBED_PROVIDER=ollama or openai."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def count_tokens(self, text: str) -> int:
|
|
82
|
+
import tiktoken
|
|
83
|
+
|
|
84
|
+
# Anthropic approximates closely with cl100k_base
|
|
85
|
+
encoding = tiktoken.get_encoding("cl100k_base")
|
|
86
|
+
return len(encoding.encode(text, disallowed_special=()))
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class LLMResponse:
|
|
9
|
+
content: str
|
|
10
|
+
prompt_tokens: int = 0
|
|
11
|
+
completion_tokens: int = 0
|
|
12
|
+
cached_tokens: int = 0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LLMProvider(ABC):
|
|
16
|
+
"""Abstract interface for LLM provider clients."""
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def generate(
|
|
20
|
+
self,
|
|
21
|
+
system_prompt: str,
|
|
22
|
+
user_prompt: str,
|
|
23
|
+
*,
|
|
24
|
+
model: str | None = None,
|
|
25
|
+
max_tokens: int | None = None,
|
|
26
|
+
temperature: float = 0.2,
|
|
27
|
+
) -> LLMResponse:
|
|
28
|
+
"""Generate a response for the given prompts."""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def embed(
|
|
33
|
+
self,
|
|
34
|
+
text: str,
|
|
35
|
+
*,
|
|
36
|
+
model: str | None = None,
|
|
37
|
+
) -> list[float]:
|
|
38
|
+
"""Generate a vector embedding for the given text."""
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def count_tokens(self, text: str) -> int:
|
|
43
|
+
"""Count the number of tokens in the given text."""
|
|
44
|
+
pass
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from synap_git.config import RuntimeProfile, SynapSettings
|
|
4
|
+
from synap_git.provider.anthropic import AnthropicProvider
|
|
5
|
+
from synap_git.provider.base import LLMProvider
|
|
6
|
+
from synap_git.provider.gemini import GeminiProvider
|
|
7
|
+
from synap_git.provider.mock import MockLLMProvider
|
|
8
|
+
from synap_git.provider.ollama import OllamaProvider
|
|
9
|
+
from synap_git.provider.openai import OpenAIProvider
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_llm_provider(settings: SynapSettings) -> LLMProvider | None:
|
|
13
|
+
"""Create an LLM provider based on runtime settings.
|
|
14
|
+
|
|
15
|
+
Strictly enforces provider configuration. Returns None for explicit Mode A
|
|
16
|
+
(structural only). Mock provider is ONLY allowed during TEST profile.
|
|
17
|
+
"""
|
|
18
|
+
if settings.profile == RuntimeProfile.TEST:
|
|
19
|
+
return MockLLMProvider()
|
|
20
|
+
|
|
21
|
+
if not settings.llm_provider:
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
provider_name = str(settings.llm_provider).lower().strip()
|
|
25
|
+
|
|
26
|
+
if provider_name == "openai":
|
|
27
|
+
if not settings.openai_api_key:
|
|
28
|
+
raise ValueError(
|
|
29
|
+
"OpenAI API key missing. Set SYNAP_OPENAI_API_KEY or "
|
|
30
|
+
"configure it in ~/.config/synap/config.toml"
|
|
31
|
+
)
|
|
32
|
+
return OpenAIProvider(
|
|
33
|
+
api_key=settings.openai_api_key,
|
|
34
|
+
default_model=settings.llm_model or "",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if provider_name == "gemini":
|
|
38
|
+
if not settings.gemini_api_key:
|
|
39
|
+
raise ValueError(
|
|
40
|
+
"Gemini API key missing. Set SYNAP_GEMINI_API_KEY or "
|
|
41
|
+
"configure it in ~/.config/synap/config.toml"
|
|
42
|
+
)
|
|
43
|
+
return GeminiProvider(
|
|
44
|
+
api_key=settings.gemini_api_key,
|
|
45
|
+
default_model=settings.llm_model or "",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if provider_name == "anthropic":
|
|
49
|
+
if not settings.anthropic_api_key:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
"Anthropic API key missing. Set SYNAP_ANTHROPIC_API_KEY or "
|
|
52
|
+
"configure it in ~/.config/synap/config.toml"
|
|
53
|
+
)
|
|
54
|
+
return AnthropicProvider(
|
|
55
|
+
api_key=settings.anthropic_api_key,
|
|
56
|
+
default_model=settings.llm_model or "",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if provider_name == "ollama":
|
|
60
|
+
return OllamaProvider(
|
|
61
|
+
base_url=settings.ollama_url,
|
|
62
|
+
default_model=settings.llm_model or "",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
raise ValueError(f"Unsupported or unconfigured LLM provider: {provider_name}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_embed_provider(settings: SynapSettings) -> LLMProvider | None:
|
|
69
|
+
"""Create an embedding provider based on runtime settings."""
|
|
70
|
+
if settings.profile == RuntimeProfile.TEST:
|
|
71
|
+
return MockLLMProvider()
|
|
72
|
+
|
|
73
|
+
if not settings.llm_provider:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
provider_name = str(settings.llm_provider).lower().strip()
|
|
77
|
+
model = settings.llm_model or ""
|
|
78
|
+
|
|
79
|
+
if provider_name == "openai":
|
|
80
|
+
if not settings.openai_api_key:
|
|
81
|
+
raise ValueError("OpenAI API key missing for embeddings.")
|
|
82
|
+
return OpenAIProvider(
|
|
83
|
+
api_key=settings.openai_api_key,
|
|
84
|
+
default_model=model,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if provider_name == "gemini":
|
|
88
|
+
if not settings.gemini_api_key:
|
|
89
|
+
raise ValueError("Gemini API key missing for embeddings.")
|
|
90
|
+
return GeminiProvider(
|
|
91
|
+
api_key=settings.gemini_api_key,
|
|
92
|
+
default_model=model,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if provider_name == "ollama":
|
|
96
|
+
return OllamaProvider(
|
|
97
|
+
base_url=settings.ollama_url,
|
|
98
|
+
default_model=model,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
raise ValueError(f"Unsupported or unconfigured embedding provider: {provider_name}")
|