nexus-coding-agent 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.
Files changed (131) hide show
  1. nexus/__init__.py +4 -0
  2. nexus/application/__init__.py +2 -0
  3. nexus/application/approval_service.py +130 -0
  4. nexus/application/diff_service.py +177 -0
  5. nexus/application/event_publisher.py +180 -0
  6. nexus/application/execution_context.py +29 -0
  7. nexus/application/execution_ledger.py +126 -0
  8. nexus/application/model_call_context.py +29 -0
  9. nexus/application/observed_model_gateway.py +194 -0
  10. nexus/application/plan_approval_service.py +250 -0
  11. nexus/application/planning.py +668 -0
  12. nexus/application/runtime.py +624 -0
  13. nexus/application/session_service.py +411 -0
  14. nexus/application/structured_output.py +96 -0
  15. nexus/application/telemetry.py +396 -0
  16. nexus/application/telemetry_subscriber.py +216 -0
  17. nexus/application/tool_runtime.py +496 -0
  18. nexus/application/tracing_dispatcher.py +283 -0
  19. nexus/application/validation.py +293 -0
  20. nexus/config/__init__.py +17 -0
  21. nexus/config/loader.py +253 -0
  22. nexus/config/models.py +310 -0
  23. nexus/context/__init__.py +6 -0
  24. nexus/context/budget.py +24 -0
  25. nexus/context/builder.py +92 -0
  26. nexus/context/chunking.py +65 -0
  27. nexus/context/explorer.py +246 -0
  28. nexus/context/integration.py +63 -0
  29. nexus/context/manager.py +328 -0
  30. nexus/context/retrieval.py +261 -0
  31. nexus/domain/__init__.py +2 -0
  32. nexus/domain/agent_decision.py +113 -0
  33. nexus/domain/agent_state.py +49 -0
  34. nexus/domain/approvals.py +64 -0
  35. nexus/domain/context.py +108 -0
  36. nexus/domain/exploration.py +86 -0
  37. nexus/domain/mcp.py +51 -0
  38. nexus/domain/model.py +173 -0
  39. nexus/domain/observability.py +326 -0
  40. nexus/domain/persistence.py +122 -0
  41. nexus/domain/planning.py +329 -0
  42. nexus/domain/ports/__init__.py +61 -0
  43. nexus/domain/ports/agent_decision.py +9 -0
  44. nexus/domain/ports/approval_unit_of_work.py +27 -0
  45. nexus/domain/ports/checkpoint_provider.py +11 -0
  46. nexus/domain/ports/context.py +71 -0
  47. nexus/domain/ports/events.py +21 -0
  48. nexus/domain/ports/graph_runtime.py +30 -0
  49. nexus/domain/ports/mcp.py +23 -0
  50. nexus/domain/ports/model_gateway.py +21 -0
  51. nexus/domain/ports/model_input_budget.py +15 -0
  52. nexus/domain/ports/planning.py +62 -0
  53. nexus/domain/ports/repositories.py +54 -0
  54. nexus/domain/ports/repository_context.py +18 -0
  55. nexus/domain/ports/session_unit_of_work.py +33 -0
  56. nexus/domain/ports/skills.py +41 -0
  57. nexus/domain/ports/tooling.py +39 -0
  58. nexus/domain/ports/tracer.py +13 -0
  59. nexus/domain/ports/validation.py +32 -0
  60. nexus/domain/runtime_events.py +445 -0
  61. nexus/domain/skills.py +129 -0
  62. nexus/domain/tooling.py +163 -0
  63. nexus/domain/validation.py +161 -0
  64. nexus/errors/__init__.py +82 -0
  65. nexus/evaluation/__init__.py +7 -0
  66. nexus/evaluation/assertions.py +4 -0
  67. nexus/evaluation/evaluator.py +263 -0
  68. nexus/evaluation/fixtures.py +36 -0
  69. nexus/evaluation/harness.py +606 -0
  70. nexus/evaluation/loader.py +415 -0
  71. nexus/evaluation/metrics.py +27 -0
  72. nexus/evaluation/models.py +362 -0
  73. nexus/evaluation/report.py +43 -0
  74. nexus/evaluation/runner.py +480 -0
  75. nexus/infrastructure/__init__.py +2 -0
  76. nexus/infrastructure/asyncio_compat.py +13 -0
  77. nexus/infrastructure/bootstrap/__init__.py +17 -0
  78. nexus/infrastructure/bootstrap/composition.py +663 -0
  79. nexus/infrastructure/checkpoint/__init__.py +5 -0
  80. nexus/infrastructure/checkpoint/postgres.py +62 -0
  81. nexus/infrastructure/database.py +39 -0
  82. nexus/infrastructure/embedding.py +61 -0
  83. nexus/infrastructure/graph/__init__.py +6 -0
  84. nexus/infrastructure/graph/day4_runtime.py +966 -0
  85. nexus/infrastructure/graph/langgraph_runtime.py +172 -0
  86. nexus/infrastructure/mcp/__init__.py +5 -0
  87. nexus/infrastructure/mcp/manager.py +380 -0
  88. nexus/infrastructure/model_gateway/__init__.py +6 -0
  89. nexus/infrastructure/model_gateway/openai_compatible.py +345 -0
  90. nexus/infrastructure/observability/__init__.py +7 -0
  91. nexus/infrastructure/observability/console.py +31 -0
  92. nexus/infrastructure/observability/langsmith.py +236 -0
  93. nexus/infrastructure/observability/structured_logger.py +70 -0
  94. nexus/infrastructure/persistence/__init__.py +13 -0
  95. nexus/infrastructure/persistence/approval_unit_of_work.py +74 -0
  96. nexus/infrastructure/persistence/models.py +146 -0
  97. nexus/infrastructure/persistence/repositories.py +326 -0
  98. nexus/infrastructure/persistence/unit_of_work.py +82 -0
  99. nexus/infrastructure/repository_files.py +117 -0
  100. nexus/infrastructure/sandbox/__init__.py +5 -0
  101. nexus/infrastructure/sandbox/local_process.py +353 -0
  102. nexus/infrastructure/semantic.py +276 -0
  103. nexus/infrastructure/semantic_models.py +56 -0
  104. nexus/interfaces/__init__.py +2 -0
  105. nexus/interfaces/cli/__init__.py +2 -0
  106. nexus/interfaces/cli/__main__.py +6 -0
  107. nexus/interfaces/cli/app.py +266 -0
  108. nexus/interfaces/cli/renderer.py +118 -0
  109. nexus/security/__init__.py +14 -0
  110. nexus/security/approval_policies.py +73 -0
  111. nexus/security/command_policy.py +117 -0
  112. nexus/security/executables.py +107 -0
  113. nexus/security/git_commands.py +65 -0
  114. nexus/security/workspace.py +93 -0
  115. nexus/skills/__init__.py +7 -0
  116. nexus/skills/builtin/__init__.py +1 -0
  117. nexus/skills/builtin/debug-python/SKILL.md +26 -0
  118. nexus/skills/builtin/review-repository/SKILL.md +26 -0
  119. nexus/skills/builtin/write-tests/SKILL.md +26 -0
  120. nexus/skills/loader.py +439 -0
  121. nexus/skills/registry.py +146 -0
  122. nexus/skills/selector.py +152 -0
  123. nexus/tools/__init__.py +6 -0
  124. nexus/tools/editing.py +312 -0
  125. nexus/tools/mcp.py +160 -0
  126. nexus/tools/native.py +654 -0
  127. nexus/tools/registry.py +27 -0
  128. nexus_coding_agent-0.1.0.dist-info/METADATA +142 -0
  129. nexus_coding_agent-0.1.0.dist-info/RECORD +131 -0
  130. nexus_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  131. nexus_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,328 @@
1
+ """Day 5 context policy, including v0.1.2's sole eviction order."""
2
+
3
+ import json
4
+ from collections.abc import Callable, Sequence
5
+ from dataclasses import asdict, replace
6
+ from pathlib import PurePosixPath
7
+
8
+ from nexus.context.builder import _applicable_instructions
9
+ from nexus.context.chunking import estimated_tokens, identity, language_for, text_hash
10
+ from nexus.context.explorer import _weakens_nexus_safety
11
+ from nexus.context.retrieval import ToolRepositoryAccess
12
+ from nexus.domain.agent_decision import Observation
13
+ from nexus.domain.context import ContextBudget, ContextCandidate, ContextRequest
14
+ from nexus.domain.exploration import RepositoryInstruction, SelectedFileContext, WorkingContext
15
+ from nexus.domain.model import ModelMessage
16
+ from nexus.domain.persistence import SessionTurn
17
+ from nexus.domain.planning import Plan
18
+ from nexus.domain.ports.context import Chunker, ContextProvider
19
+ from nexus.errors import ContextError
20
+
21
+
22
+ def context_payload(context: WorkingContext) -> dict[str, object]:
23
+ """Only the selected view is model-visible; ranking evidence is not a second code copy."""
24
+ return {
25
+ "task": context.task,
26
+ "repository_instructions": [asdict(item) for item in context.repository_instructions],
27
+ "selected_files": [asdict(item) for item in context.selected_files],
28
+ "manifests": [asdict(item) for item in context.manifest_summaries],
29
+ "top_level_paths": context.top_level_paths,
30
+ "observations": [asdict(item) for item in context.recent_observations],
31
+ "conversation": [
32
+ {"role": item.role, "content": item.content}
33
+ for item in context.recent_conversation_turns
34
+ ],
35
+ "compacted_observations": context.compacted_observations,
36
+ "compacted_conversation": context.compacted_conversation,
37
+ "selected_skills": [
38
+ {
39
+ "skill_id": item.metadata.skill_id,
40
+ "name": item.metadata.name,
41
+ "version": item.metadata.version,
42
+ "source": item.metadata.source.value,
43
+ "selected_reason": item.selected_reason,
44
+ "body": item.body,
45
+ }
46
+ for item in context.selected_skills
47
+ ],
48
+ }
49
+
50
+
51
+ def model_input_tokens(messages: Sequence[ModelMessage]) -> int:
52
+ # Count complete serialized messages, including roles/JSON escaping, plus a small
53
+ # conservative envelope estimate. This remains the approved character heuristic.
54
+ serialized = json.dumps([asdict(message) for message in messages], ensure_ascii=False)
55
+ return estimated_tokens(serialized) + 16 * len(messages) + 16
56
+
57
+
58
+ def _observation_summary(observations: Sequence[Observation]) -> str | None:
59
+ if not observations:
60
+ return None
61
+ return "\n".join(
62
+ f"{o.tool_name}: success={o.success}; error={o.error_code}; "
63
+ f"evidence={o.evidence_summary[:256]}; replan={o.replan_reason}"
64
+ for o in observations
65
+ )
66
+
67
+
68
+ def _conversation_summary(turns: Sequence[SessionTurn]) -> str | None:
69
+ return "\n".join(f"{t.role}: {t.content[:256]}" for t in turns) or None
70
+
71
+
72
+ class BoundedContextManager:
73
+ def __init__(
74
+ self,
75
+ provider: ContextProvider,
76
+ access: ToolRepositoryAccess,
77
+ chunker: Chunker,
78
+ budget: ContextBudget,
79
+ max_model_input_tokens: int,
80
+ ) -> None:
81
+ self._provider = provider
82
+ self._access = access
83
+ self._chunker = chunker
84
+ self._budget = budget
85
+ self._maximum = max_model_input_tokens
86
+
87
+ async def build(self, request: ContextRequest) -> WorkingContext:
88
+ with self._access.scope(request.run_id, request.session_id):
89
+ retrieval = await self._provider.retrieve(request)
90
+ seeds: list[ContextCandidate] = []
91
+ for evidence in request.exploration.relevant_files[:8]:
92
+ if not await self._access.allowed(evidence.path):
93
+ continue
94
+ content = await self._access.read(evidence.path)
95
+ if not content:
96
+ continue
97
+ chunks = self._chunker.chunk(
98
+ file_path=evidence.path,
99
+ language=language_for(evidence.path),
100
+ content=content,
101
+ file_hash=text_hash(content),
102
+ )
103
+ seeds.extend(
104
+ ContextCandidate(c, None, None, None, 0.0, ())
105
+ for c in chunks
106
+ if c.start_line <= 400
107
+ )
108
+ unique: dict[tuple[str, int, int, str], tuple[ContextCandidate, str]] = {}
109
+ seed_limit_reached = False
110
+ for candidate in seeds:
111
+ key = identity(candidate)
112
+ if key in unique:
113
+ continue
114
+ if len(unique) >= self._budget.max_exploration_seed_chunks:
115
+ seed_limit_reached = True
116
+ continue
117
+ unique[key] = (candidate, "exploration seed")
118
+ for candidate in retrieval.candidates:
119
+ unique.setdefault(identity(candidate), (candidate, "hybrid retrieval"))
120
+ selected: list[SelectedFileContext] = []
121
+ instructions = list(request.exploration.instructions)
122
+ for instruction in tuple(instructions):
123
+ if instruction.truncated:
124
+ content = await self._access.read(instruction.path)
125
+ if content is None:
126
+ raise ContextError(
127
+ "Required repository instructions could not be read.",
128
+ code="CONTEXT_BUILD_FAILED",
129
+ )
130
+ instructions[instructions.index(instruction)] = replace(
131
+ instruction,
132
+ content=content,
133
+ truncated=False,
134
+ )
135
+ tokens = 0
136
+ truncated = request.exploration.truncated or seed_limit_reached
137
+ for candidate, reason in unique.values():
138
+ chunk = candidate.chunk
139
+ cost = estimated_tokens(chunk.content)
140
+ if (
141
+ len(selected) >= self._budget.max_retrieved_chunks
142
+ or tokens + cost > self._budget.max_code_context_tokens
143
+ ):
144
+ truncated = True
145
+ continue
146
+ if not await self._access.allowed(chunk.file_path):
147
+ truncated = True
148
+ continue
149
+ # Never present deleted/changed/now-ignored indexed text as current code.
150
+ current = await self._access.read(chunk.file_path)
151
+ if current is None or text_hash(current) != chunk.file_hash:
152
+ truncated = True
153
+ continue
154
+ await self._instructions(chunk.file_path, instructions)
155
+ selected.append(
156
+ SelectedFileContext(
157
+ chunk.file_path,
158
+ chunk.content,
159
+ _applicable_instructions(chunk.file_path, tuple(instructions)),
160
+ f"{reason}; lines {chunk.start_line}-{chunk.end_line}",
161
+ False,
162
+ )
163
+ )
164
+ tokens += cost
165
+ context = WorkingContext(
166
+ request.task,
167
+ tuple(instructions),
168
+ request.exploration.manifests,
169
+ request.exploration.top_level_paths,
170
+ tuple(selected),
171
+ truncated,
172
+ retrieval.candidates,
173
+ None,
174
+ retrieval.semantic_used,
175
+ retrieval.semantic_status,
176
+ )
177
+ return await self.prepare_agent_context(
178
+ working_context=context,
179
+ plan=None,
180
+ observations=(),
181
+ conversation_turns=(),
182
+ )
183
+
184
+ async def _instructions(self, path: str, instructions: list[RepositoryInstruction]) -> None:
185
+ known = {item.path for item in instructions if not item.truncated}
186
+ for parent in reversed(PurePosixPath(path).parents):
187
+ name = str(parent / "AGENTS.md")
188
+ if name in known:
189
+ continue
190
+ content = await self._access.read(name)
191
+ if content is None:
192
+ if any(item.path == name for item in instructions):
193
+ raise ContextError(
194
+ "Required repository instructions could not be read.",
195
+ code="CONTEXT_BUILD_FAILED",
196
+ )
197
+ continue
198
+ if _weakens_nexus_safety(content):
199
+ raise ContextError(
200
+ "Repository instructions conflict with Nexus safety.",
201
+ code="REPOSITORY_INSTRUCTION_CONFLICT",
202
+ )
203
+ instructions[:] = [item for item in instructions if item.path != name]
204
+ instructions.append(
205
+ RepositoryInstruction(
206
+ name,
207
+ str(parent),
208
+ len(parent.parts),
209
+ content,
210
+ False,
211
+ )
212
+ )
213
+ instructions.sort(key=lambda item: (item.depth, item.path))
214
+
215
+ async def prepare_agent_context(
216
+ self,
217
+ *,
218
+ working_context: WorkingContext,
219
+ plan: Plan | None,
220
+ observations: Sequence[Observation],
221
+ conversation_turns: Sequence[SessionTurn],
222
+ ) -> WorkingContext:
223
+ count = self._budget.max_recent_observations
224
+ turns = tuple(sorted(conversation_turns, key=lambda turn: turn.sequence))
225
+ recent_observations = tuple(observations[-count:]) if count else ()
226
+ older_observations = observations[:-count] if count else observations
227
+ context = replace(
228
+ working_context,
229
+ recent_observations=recent_observations,
230
+ compacted_observations=_observation_summary(older_observations),
231
+ recent_conversation_turns=turns[-6:],
232
+ compacted_conversation=_conversation_summary(turns[:-6]),
233
+ )
234
+
235
+ def render(view: WorkingContext) -> Sequence[ModelMessage]:
236
+ payload = context_payload(view)
237
+ payload["plan"] = None if plan is None else asdict(plan)
238
+ return [ModelMessage("user", json.dumps(payload, default=str, ensure_ascii=False))]
239
+
240
+ return self.fit_model_input(context, render)
241
+
242
+ def fit_model_input(
243
+ self,
244
+ context: WorkingContext,
245
+ render: Callable[[WorkingContext], Sequence[ModelMessage]],
246
+ ) -> WorkingContext:
247
+ """Fit the actual final prompt, including system and adapter-specific fields.
248
+
249
+ Rendering is pure; all reductions and their order remain owned here. The callback
250
+ also covers Plan/repair prompts without changing their public provider contracts.
251
+ """
252
+
253
+ def fits() -> bool:
254
+ return model_input_tokens(render(context)) <= self._maximum
255
+
256
+ if fits():
257
+ return context
258
+ # 1. Reverse selection priority: hybrid last, seed first in the original view.
259
+ while context.selected_files and not fits():
260
+ context = replace(context, selected_files=context.selected_files[:-1], truncated=True)
261
+ # 2. Older observations become bounded factual summaries before recent ones.
262
+ while context.recent_observations and not fits():
263
+ summary = _observation_summary(context.recent_observations[:1])
264
+ context = replace(
265
+ context,
266
+ recent_observations=context.recent_observations[1:],
267
+ truncated=True,
268
+ compacted_observations="\n".join(
269
+ filter(
270
+ None,
271
+ (
272
+ context.compacted_observations,
273
+ summary,
274
+ ),
275
+ )
276
+ ),
277
+ )
278
+ # 3. Conversation reduction cannot run before the observation stage.
279
+ while context.recent_conversation_turns and not fits():
280
+ summary = _conversation_summary(context.recent_conversation_turns[:1])
281
+ context = replace(
282
+ context,
283
+ recent_conversation_turns=context.recent_conversation_turns[1:],
284
+ compacted_conversation="\n".join(
285
+ filter(
286
+ None,
287
+ (
288
+ context.compacted_conversation,
289
+ summary,
290
+ ),
291
+ )
292
+ ),
293
+ truncated=True,
294
+ )
295
+ # 4. Non-authoritative repository evidence.
296
+ while context.manifest_summaries and not fits():
297
+ context = replace(
298
+ context, manifest_summaries=context.manifest_summaries[:-1], truncated=True
299
+ )
300
+ while context.top_level_paths and not fits():
301
+ context = replace(context, top_level_paths=context.top_level_paths[:-1], truncated=True)
302
+ # 5. Remaining optional compacted context, oldest entries first.
303
+ for field in ("compacted_conversation", "compacted_observations"):
304
+ while getattr(context, field) and not fits():
305
+ value = str(getattr(context, field))
306
+ reduced = "\n".join(value.splitlines()[1:]) or None
307
+ context = (
308
+ replace(context, compacted_conversation=reduced, truncated=True)
309
+ if field == "compacted_conversation"
310
+ else replace(context, compacted_observations=reduced, truncated=True)
311
+ )
312
+ while len(context.selected_skills) > 1 and not fits():
313
+ context = replace(
314
+ context,
315
+ selected_skills=context.selected_skills[:-1],
316
+ truncated=True,
317
+ )
318
+ if context.selected_skills and not fits():
319
+ raise ContextError(
320
+ "The first selected Skill cannot fit with required authoritative context.",
321
+ code="SKILL_CONTEXT_BUDGET_EXCEEDED",
322
+ )
323
+ if not fits():
324
+ raise ContextError(
325
+ "Required authoritative context exceeds the configured model input budget.",
326
+ code="CONTEXT_BUILD_FAILED",
327
+ )
328
+ return context
@@ -0,0 +1,261 @@
1
+ """Policy-safe lexical search and exact Reciprocal Rank Fusion."""
2
+
3
+ import re
4
+ from collections.abc import Iterator
5
+ from contextlib import contextmanager
6
+ from contextvars import ContextVar
7
+ from dataclasses import replace
8
+ from pathlib import PurePosixPath
9
+ from uuid import uuid4
10
+
11
+ from pathspec import GitIgnoreSpec
12
+
13
+ from nexus.application.tool_runtime import ToolRuntime
14
+ from nexus.context.chunking import identity, language_for, normalize_text, text_hash
15
+ from nexus.domain.context import (
16
+ ContextCandidate,
17
+ ContextRequest,
18
+ IndexCompatibilityStatus,
19
+ RetrievalQuery,
20
+ RetrievalResult,
21
+ RetrievalSource,
22
+ )
23
+ from nexus.domain.ports.context import Chunker, LexicalSearchProvider, SemanticSearchProvider
24
+ from nexus.domain.tooling import ToolInvocation
25
+ from nexus.errors import ContextError, PermissionDeniedError
26
+
27
+ _EXCLUDED = frozenset(
28
+ {
29
+ ".git",
30
+ "node_modules",
31
+ ".venv",
32
+ "venv",
33
+ "__pycache__",
34
+ "dist",
35
+ "build",
36
+ "target",
37
+ "coverage",
38
+ ".pytest_cache",
39
+ ".mypy_cache",
40
+ ".ruff_cache",
41
+ }
42
+ )
43
+
44
+
45
+ class ToolRepositoryAccess:
46
+ def __init__(self, runtime: ToolRuntime) -> None:
47
+ self._runtime = runtime
48
+ self._scope: ContextVar[tuple[str, str]] = ContextVar("retrieval_tool_scope")
49
+ self._cache: ContextVar[dict[str, str | None]] = ContextVar("retrieval_file_cache")
50
+
51
+ @contextmanager
52
+ def scope(self, run_id: str, session_id: str) -> Iterator[None]:
53
+ token = self._scope.set((run_id, session_id))
54
+ cache = self._cache.set({})
55
+ try:
56
+ yield
57
+ finally:
58
+ self._cache.reset(cache)
59
+ self._scope.reset(token)
60
+
61
+ async def invoke(self, name: str, arguments: dict[str, object]) -> dict[str, object] | None:
62
+ run_id, session_id = self._scope.get()
63
+ result = await self._runtime.execute(
64
+ ToolInvocation(str(uuid4()), name, arguments, run_id, session_id),
65
+ )
66
+ if result.error is not None and result.error.code == "WORKSPACE_PATH_DENIED":
67
+ raise PermissionDeniedError(
68
+ "Repository read was denied by workspace policy.", code="WORKSPACE_PATH_DENIED"
69
+ )
70
+ if (
71
+ name == "read_file"
72
+ and str(arguments.get("path", "")).endswith("AGENTS.md")
73
+ and result.error is not None
74
+ and result.error.code == "UNSUPPORTED_FILE"
75
+ ):
76
+ raise ContextError(
77
+ "Applicable repository instructions cannot be read safely.",
78
+ code="CONTEXT_BUILD_FAILED",
79
+ )
80
+ return result.output if result.success else None
81
+
82
+ async def read(self, path: str) -> str | None:
83
+ cache = self._cache.get()
84
+ if path in cache:
85
+ return cache[path]
86
+ parts: list[str] = []
87
+ start = 1
88
+ while True:
89
+ output = await self.invoke(
90
+ "read_file", {"path": path, "start_line": start, "max_lines": 400}
91
+ )
92
+ if output is None or not isinstance(output.get("content"), str):
93
+ cache[path] = None
94
+ return None
95
+ content = str(output["content"])
96
+ parts.append(content)
97
+ if not output.get("truncated"):
98
+ break
99
+ end = output.get("end_line")
100
+ if not isinstance(end, int) or end < start or not content:
101
+ cache[path] = None
102
+ return None
103
+ start = end + 1
104
+ cache[path] = normalize_text("".join(parts))
105
+ return cache[path]
106
+
107
+ async def allowed(self, path: str) -> bool:
108
+ target = PurePosixPath(path.replace("\\", "/"))
109
+ if (
110
+ target.is_absolute()
111
+ or ".." in target.parts
112
+ or any(p in _EXCLUDED for p in target.parts)
113
+ or target.name == "AGENTS.md"
114
+ ):
115
+ return False
116
+ ignored = False
117
+ parents = list(reversed(target.parents))
118
+ for parent in parents:
119
+ ignore = await self.read(str(parent / ".gitignore"))
120
+ if ignore is None:
121
+ continue
122
+ spec = GitIgnoreSpec.from_lines(ignore.splitlines())
123
+ relative = target.relative_to(parent).as_posix()
124
+ parts = relative.split("/")
125
+ if any(spec.match_file("/".join(parts[:i]) + "/") for i in range(1, len(parts))):
126
+ return False
127
+ result = spec.check_file(relative)
128
+ if result.include is not None:
129
+ ignored = result.include
130
+ return not ignored
131
+
132
+
133
+ def lexical_terms(task: str) -> tuple[str, ...]:
134
+ quoted = re.findall(r'`([^`]+)`|"([^"\n]+)"|\x27([^\x27\n]+)\x27', task)
135
+ tokens = re.findall(r"[A-Za-z_][A-Za-z0-9_.:/\\-]*", task)
136
+ paths = [token for token in tokens if "/" in token or "\\" in token]
137
+ words = re.findall(r"[^\W_]{3,}", task, flags=re.UNICODE)
138
+ ignored = {"the", "and", "for", "with", "from", "into", "this", "that", "please"}
139
+ ordered = [next(x for x in match if x) for match in quoted] + paths + tokens + words
140
+ return tuple(dict.fromkeys(term for term in ordered if term.casefold() not in ignored))
141
+
142
+
143
+ class ToolLexicalSearchProvider:
144
+ def __init__(self, access: ToolRepositoryAccess, chunker: Chunker) -> None:
145
+ self._access = access
146
+ self._chunker = chunker
147
+
148
+ async def search(self, query: RetrievalQuery) -> tuple[ContextCandidate, ...]:
149
+ candidates: dict[tuple[str, int, int, str], ContextCandidate] = {}
150
+ for term in lexical_terms(query.text):
151
+ output = await self._access.invoke(
152
+ "lexical_search",
153
+ {
154
+ "pattern": term,
155
+ "path": ".",
156
+ "case_sensitive": True,
157
+ },
158
+ )
159
+ if output is None:
160
+ continue
161
+ matches = output.get("matches", [])
162
+ if not isinstance(matches, list):
163
+ continue
164
+ for match in matches:
165
+ if not isinstance(match, dict):
166
+ continue
167
+ path, line = match.get("path"), match.get("line")
168
+ if not isinstance(path, str) or not isinstance(line, int):
169
+ continue
170
+ if not await self._access.allowed(path):
171
+ continue
172
+ content = await self._access.read(path)
173
+ if not content:
174
+ continue
175
+ windows = self._chunker.chunk(
176
+ file_path=path,
177
+ language=language_for(path),
178
+ content=content,
179
+ file_hash=text_hash(content),
180
+ )
181
+ start = 1 if len(content.splitlines()) <= 120 else 1 + ((line - 1) // 100) * 100
182
+ chunk = next((c for c in windows if c.start_line == start), None)
183
+ if chunk is None:
184
+ continue
185
+ candidate = ContextCandidate(
186
+ chunk, len(candidates) + 1, None, None, 0.0, (RetrievalSource.LEXICAL,)
187
+ )
188
+ candidates.setdefault(identity(candidate), candidate)
189
+ if len(candidates) >= min(query.limit, 20):
190
+ return tuple(candidates.values())
191
+ return tuple(candidates.values())
192
+
193
+
194
+ def rrf_merge(
195
+ lexical: tuple[ContextCandidate, ...],
196
+ semantic: tuple[ContextCandidate, ...],
197
+ ) -> tuple[ContextCandidate, ...]:
198
+ merged: dict[tuple[str, int, int, str], ContextCandidate] = {}
199
+ for candidate in (*lexical, *semantic):
200
+ key = identity(candidate)
201
+ previous = merged.get(key)
202
+ if previous is not None:
203
+ candidate = replace(
204
+ previous,
205
+ lexical_rank=previous.lexical_rank or candidate.lexical_rank,
206
+ semantic_rank=previous.semantic_rank or candidate.semantic_rank,
207
+ semantic_score=(
208
+ previous.semantic_score
209
+ if previous.semantic_rank is not None
210
+ else candidate.semantic_score
211
+ ),
212
+ sources=tuple(
213
+ source
214
+ for source in RetrievalSource
215
+ if source in previous.sources or source in candidate.sources
216
+ ),
217
+ )
218
+ ranks = [r for r in (candidate.lexical_rank, candidate.semantic_rank) if r is not None]
219
+ merged[key] = replace(candidate, rrf_score=sum(1 / (60 + rank) for rank in ranks))
220
+ return tuple(
221
+ sorted(
222
+ merged.values(),
223
+ key=lambda c: (
224
+ -c.rrf_score,
225
+ min(r for r in (c.lexical_rank, c.semantic_rank) if r is not None),
226
+ c.chunk.file_path,
227
+ c.chunk.start_line,
228
+ c.chunk.content_hash,
229
+ ),
230
+ )
231
+ )
232
+
233
+
234
+ class HybridContextProvider:
235
+ def __init__(
236
+ self,
237
+ lexical: LexicalSearchProvider,
238
+ semantic: SemanticSearchProvider | None,
239
+ ) -> None:
240
+ self._lexical = lexical
241
+ self._semantic = semantic
242
+
243
+ async def retrieve(self, request: ContextRequest) -> RetrievalResult:
244
+ query = RetrievalQuery(request.repository_id, request.workspace_path, request.task, 20)
245
+ lexical = await self._lexical.search(query)
246
+ semantic: tuple[ContextCandidate, ...] = ()
247
+ status: str | None = None
248
+ used = False
249
+ if self._semantic is not None:
250
+ try:
251
+ compatible = await self._semantic.validate_index(request.repository_id)
252
+ if compatible.status is IndexCompatibilityStatus.COMPATIBLE:
253
+ semantic = await self._semantic.search(query)
254
+ used = True
255
+ else:
256
+ status = compatible.status.value
257
+ except ContextError as exc:
258
+ status = exc.code
259
+ except Exception:
260
+ status = "SEMANTIC_SEARCH_FAILED"
261
+ return RetrievalResult(rrf_merge(lexical, semantic)[:12], used, status)
@@ -0,0 +1,2 @@
1
+ """Nexus-owned domain values and ports."""
2
+