vectr 1.0.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.
- agent/__init__.py +0 -0
- agent/cartographer.py +428 -0
- agent/chunk_quality.py +1119 -0
- agent/config.py +648 -0
- agent/config.yaml +1153 -0
- agent/eviction_advisor.py +447 -0
- agent/identifier_hint.py +68 -0
- agent/indexer/__init__.py +112 -0
- agent/indexer/_chunking.py +458 -0
- agent/indexer/_constants.py +105 -0
- agent/indexer/_core.py +878 -0
- agent/indexer/_types.py +157 -0
- agent/instance_registry.py +127 -0
- agent/llm_client.py +8 -0
- agent/model_cache.py +101 -0
- agent/prompt_templates.py +31 -0
- agent/searcher.py +848 -0
- agent/strategy_selector.py +299 -0
- agent/symbol_graph/__init__.py +131 -0
- agent/symbol_graph/_constants.py +366 -0
- agent/symbol_graph/_extraction.py +528 -0
- agent/symbol_graph/_graph.py +1878 -0
- agent/symbol_graph/_types.py +35 -0
- agent/templates/claude_md.md +65 -0
- agent/templates/claude_md_search_only.md +27 -0
- agent/templates/cursor_mcp.json.template +7 -0
- agent/templates/cursor_rules_header.txt +5 -0
- agent/templates/hook_no_double_recall.txt +1 -0
- agent/templates/mcp.json.template +8 -0
- agent/templates/session_start_guidance_default.txt +3 -0
- agent/templates/session_start_guidance_hooks_aware.txt +1 -0
- agent/templates/tool_loading_guidance_claude.txt +1 -0
- agent/templates/tool_loading_guidance_claude_search_only.txt +1 -0
- agent/templates/vscode_mcp.json.template +8 -0
- agent/tool_necessity_probe.py +177 -0
- agent/version_stamp.py +58 -0
- agent/watcher.py +515 -0
- agent/working_context_store/__init__.py +76 -0
- agent/working_context_store/_audit.py +46 -0
- agent/working_context_store/_encryption.py +75 -0
- agent/working_context_store/_store.py +1130 -0
- agent/working_context_store/_types.py +50 -0
- api.py +101 -0
- app/__init__.py +0 -0
- app/models.py +368 -0
- app/routes.py +451 -0
- app/service.py +1055 -0
- integrations/__init__.py +0 -0
- integrations/mcp_server/__init__.py +73 -0
- integrations/mcp_server/_dispatch.py +782 -0
- integrations/mcp_server/_schemas.py +484 -0
- integrations/mcp_server/_session.py +86 -0
- integrations/vscode_bridge.py +71 -0
- integrations/workspace_detect.py +259 -0
- main.py +2037 -0
- vectr-1.0.0.dist-info/METADATA +37 -0
- vectr-1.0.0.dist-info/RECORD +61 -0
- vectr-1.0.0.dist-info/WHEEL +5 -0
- vectr-1.0.0.dist-info/entry_points.txt +2 -0
- vectr-1.0.0.dist-info/licenses/LICENSE +21 -0
- vectr-1.0.0.dist-info/top_level.txt +5 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""
|
|
2
|
+
EvictionAdvisor — tells the LLM which chunks vectr can re-retrieve in <50ms.
|
|
3
|
+
|
|
4
|
+
The guarantee: anything listed here is fully indexed and re-retrievable in <50ms.
|
|
5
|
+
This is the reverse signal in the bidirectional protocol. The LLM calls vectr_remember
|
|
6
|
+
to save notes; the EvictionAdvisor proactively signals when retrieved content
|
|
7
|
+
is fully indexed and can be re-retrieved without re-reading the file.
|
|
8
|
+
|
|
9
|
+
The advisor tracks which chunks have been retrieved in the current session and
|
|
10
|
+
estimates their token cost. When the session hits a threshold, it fires an
|
|
11
|
+
eviction hint.
|
|
12
|
+
|
|
13
|
+
Limitation — Read/Bash blind spot (E2):
|
|
14
|
+
The advisor only tracks content delivered through vectr's own tools (vectr_search,
|
|
15
|
+
vectr_locate, vectr_trace, vectr_recall). Code the agent reads via the IDE's native
|
|
16
|
+
Read or Bash tools is invisible here. Token estimates are therefore a lower bound;
|
|
17
|
+
eviction may fire later than ideal on sessions that mix vectr retrieval with direct
|
|
18
|
+
file reading. The gap shrinks as agents adopt vectr_search as their primary navigation
|
|
19
|
+
path (Problem 3). Track `vectr_evict_hint_triggered` in benchmarks (E5) to monitor
|
|
20
|
+
real-world eviction uptake.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import time
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
|
|
27
|
+
from agent.config import (
|
|
28
|
+
EVICTION_HINT_MAX_IDS,
|
|
29
|
+
EVICTION_REMEMBER_ESCALATION_CHUNKS,
|
|
30
|
+
EVICTION_REMEMBER_ESCALATION_TOKENS,
|
|
31
|
+
EVICTION_RETRIEVED_TOKEN_GATE,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class RetrievedChunk:
|
|
37
|
+
file_path: str
|
|
38
|
+
lines: str
|
|
39
|
+
symbol_name: str
|
|
40
|
+
content: str
|
|
41
|
+
# UPG-EVICT-SESSION-SCOPE: the exact vectr_fetch re-fetch key for this chunk
|
|
42
|
+
# (`file_path:start_line-end_line`), when the caller had one. Empty for
|
|
43
|
+
# chunks recorded from a surface that doesn't guarantee an exact indexed
|
|
44
|
+
# chunk id (e.g. a symbol-graph snippet whose line range may not match a
|
|
45
|
+
# stored chunk boundary) — never guessed, so eviction_hint() only ever
|
|
46
|
+
# advertises a re-fetch key that is known to work.
|
|
47
|
+
chunk_id: str = ""
|
|
48
|
+
retrieved_at: float = field(default_factory=time.time)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def estimated_tokens(self) -> int:
|
|
52
|
+
return max(1, len(self.content) // 4)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class EvictionAdvisor:
|
|
56
|
+
"""
|
|
57
|
+
Tracks what the LLM has retrieved this session and advises on eviction.
|
|
58
|
+
|
|
59
|
+
Usage:
|
|
60
|
+
advisor = EvictionAdvisor(eviction_threshold_tokens=4000)
|
|
61
|
+
advisor.record(chunk) # call after every vectr_search result
|
|
62
|
+
hint = advisor.eviction_hint() # returns text the LLM can act on
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
eviction_threshold_tokens: int = 40_000,
|
|
68
|
+
tool_call_threshold: int = 10,
|
|
69
|
+
retrieval_call_threshold: int = 1,
|
|
70
|
+
time_threshold_seconds: float = 180.0,
|
|
71
|
+
rearm_retrieval_calls: int = 4,
|
|
72
|
+
retrieved_token_gate: int | None = None,
|
|
73
|
+
remember_escalation_chunks: int | None = None,
|
|
74
|
+
remember_escalation_tokens: int | None = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
# Fire when ANY of these conditions is met:
|
|
77
|
+
# cumulative injected chars ÷ 4 >= 40K (vectr-tracked content)
|
|
78
|
+
# tool_call_count > 10 (all MCP calls this session)
|
|
79
|
+
# retrieval_call_count > 1 (search/locate/trace calls only)
|
|
80
|
+
# elapsed seconds >= 180 (wall-clock since session start)
|
|
81
|
+
#
|
|
82
|
+
# Why retrieval_call_threshold: the tool_call_count trigger counts all vectr MCP
|
|
83
|
+
# calls, but the LLM typically makes only 3-4 per task (status + 1 locate + 2 search)
|
|
84
|
+
# so threshold=10 never fires. Counting only retrieval calls (search/locate/trace)
|
|
85
|
+
# and firing on the 2nd one gives a natural mid-task trigger: the LLM has found
|
|
86
|
+
# something worth keeping before it switches into implementation mode.
|
|
87
|
+
#
|
|
88
|
+
# Why time_threshold: the eviction protocol requires vectr to see every retrieval
|
|
89
|
+
# to track real context pressure (spec line 78), but the LLM uses native Read/Bash
|
|
90
|
+
# for most reading — those calls are invisible here. A time-based trigger fires
|
|
91
|
+
# regardless of tool mix, catching sessions where the LLM barely uses vectr at all.
|
|
92
|
+
# arXiv:2310.08560, arXiv:2510.24699.
|
|
93
|
+
self._threshold = eviction_threshold_tokens
|
|
94
|
+
self._tool_call_threshold = tool_call_threshold
|
|
95
|
+
self._retrieval_call_threshold = retrieval_call_threshold
|
|
96
|
+
self._time_threshold_seconds = time_threshold_seconds
|
|
97
|
+
# UPG-7.1: re-arm the auto-footer only after this many further retrieval
|
|
98
|
+
# calls (or a fresh token/time crossing) since the last time it fired —
|
|
99
|
+
# so the footer can't repeat on every response.
|
|
100
|
+
self._rearm_retrieval_calls = rearm_retrieval_calls
|
|
101
|
+
# UPG-11.15: suppress auto_eviction_hint() when accumulated retrieved
|
|
102
|
+
# tokens since the last hint (or session start) are below this gate.
|
|
103
|
+
# Prevents a burst of small-result searches from triggering the full
|
|
104
|
+
# ACTION REQUIRED block before real context pressure has accumulated.
|
|
105
|
+
# None → use the config value (EVICTION_RETRIEVED_TOKEN_GATE).
|
|
106
|
+
self._retrieved_token_gate: int = (
|
|
107
|
+
retrieved_token_gate if retrieved_token_gate is not None
|
|
108
|
+
else EVICTION_RETRIEVED_TOKEN_GATE
|
|
109
|
+
)
|
|
110
|
+
# UPG-REMEMBER-BANNER-FATIGUE: gates auto_eviction_hint()'s escalated
|
|
111
|
+
# directive on chunks retrieved since the last vectr_remember, not
|
|
112
|
+
# just on raw context pressure — see note_remembered().
|
|
113
|
+
self._remember_escalation_chunks: int = (
|
|
114
|
+
remember_escalation_chunks if remember_escalation_chunks is not None
|
|
115
|
+
else EVICTION_REMEMBER_ESCALATION_CHUNKS
|
|
116
|
+
)
|
|
117
|
+
# UPG-EVICT-ESCALATION-GATE-TOO-LOW: companion gate required IN ADDITION
|
|
118
|
+
# to _remember_escalation_chunks — see note_remembered() and
|
|
119
|
+
# _tokens_since_remember(). A single large search can satisfy the chunk
|
|
120
|
+
# gate alone in one burst; requiring both makes that burst insufficient
|
|
121
|
+
# on its own to re-trip the escalated directive.
|
|
122
|
+
self._remember_escalation_tokens: int = (
|
|
123
|
+
remember_escalation_tokens if remember_escalation_tokens is not None
|
|
124
|
+
else EVICTION_REMEMBER_ESCALATION_TOKENS
|
|
125
|
+
)
|
|
126
|
+
self._chunks: list[RetrievedChunk] = []
|
|
127
|
+
self._tool_call_count: int = 0
|
|
128
|
+
self._retrieval_call_count: int = 0
|
|
129
|
+
self._session_started_at: float = time.time()
|
|
130
|
+
# (tokens, retrieval_count, wall_time) recorded the last time the auto
|
|
131
|
+
# footer emitted; None until the first emit. Gates auto_eviction_hint().
|
|
132
|
+
self._last_emit: tuple[int, int, float] | None = None
|
|
133
|
+
# New chunks recorded since the caller's last vectr_remember (or
|
|
134
|
+
# session start). Reset by note_remembered(). See
|
|
135
|
+
# EVICTION_REMEMBER_ESCALATION_CHUNKS.
|
|
136
|
+
self._chunks_since_remember: int = 0
|
|
137
|
+
# Tracked total-token count as of the caller's last vectr_remember (or
|
|
138
|
+
# 0 at session start). _tokens_since_remember() subtracts this baseline
|
|
139
|
+
# from the running total (UPG-EVICT-ESCALATION-GATE-TOO-LOW).
|
|
140
|
+
self._tokens_at_last_remember: int = 0
|
|
141
|
+
# True once note_remembered() has fired and no escalated (ACTION
|
|
142
|
+
# REQUIRED) directive has re-emitted since. While True, the NEXT
|
|
143
|
+
# eligible auto-hint renders the softer, non-imperative form instead
|
|
144
|
+
# of escalating immediately — repeating harsh wording on the very
|
|
145
|
+
# next search after compliance trains the caller to ignore it.
|
|
146
|
+
# Starts False: before the caller has ever called vectr_remember,
|
|
147
|
+
# auto_eviction_hint() escalates immediately as before.
|
|
148
|
+
self._soft_fire_pending: bool = False
|
|
149
|
+
|
|
150
|
+
def record(
|
|
151
|
+
self, file_path: str, lines: str, symbol_name: str, content: str,
|
|
152
|
+
chunk_id: str = "",
|
|
153
|
+
) -> None:
|
|
154
|
+
"""Record a chunk that was delivered to the LLM this session."""
|
|
155
|
+
# avoid duplicate tracking for the same file:lines
|
|
156
|
+
key = f"{file_path}:{lines}"
|
|
157
|
+
for i, c in enumerate(self._chunks):
|
|
158
|
+
if f"{c.file_path}:{c.lines}" == key:
|
|
159
|
+
# UPG-EVICT-RECENCY-DEDUP-BLIND: a repeat retrieval of the exact
|
|
160
|
+
# same chunk (the LLM re-running the same query and getting the
|
|
161
|
+
# same top hit) is the common case, and eviction_hint()'s
|
|
162
|
+
# most-recently-retrieved-first ordering keys recency off a
|
|
163
|
+
# file's position in self._chunks. Without this move, re-touching
|
|
164
|
+
# a file never refreshes that position, so the file the caller
|
|
165
|
+
# just retrieved again silently renders LAST instead of first.
|
|
166
|
+
# Move the existing entry to the end (fresh recency) without
|
|
167
|
+
# storing a duplicate or advancing any counter below — a dedup
|
|
168
|
+
# hit must be a no-op for token/chunk totals, only recency moves.
|
|
169
|
+
self._chunks.append(self._chunks.pop(i))
|
|
170
|
+
return
|
|
171
|
+
self._chunks.append(RetrievedChunk(
|
|
172
|
+
file_path=file_path,
|
|
173
|
+
lines=lines,
|
|
174
|
+
symbol_name=symbol_name,
|
|
175
|
+
content=content,
|
|
176
|
+
chunk_id=chunk_id,
|
|
177
|
+
))
|
|
178
|
+
self._chunks_since_remember += 1
|
|
179
|
+
|
|
180
|
+
def record_results(self, results: list) -> None:
|
|
181
|
+
"""Record a batch of SearchResult objects (from searcher.py)."""
|
|
182
|
+
for r in results:
|
|
183
|
+
self.record(
|
|
184
|
+
file_path=r.file_path,
|
|
185
|
+
lines=str(r.lines),
|
|
186
|
+
symbol_name=r.symbol_name or "",
|
|
187
|
+
content=r.content,
|
|
188
|
+
chunk_id=getattr(r, "chunk_id", "") or "",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def note_remembered(self) -> None:
|
|
192
|
+
"""Call when the caller's vectr_remember succeeds this session.
|
|
193
|
+
|
|
194
|
+
Resets the chunks-since-last-remember counter and the tokens-since-
|
|
195
|
+
last-remember baseline so auto_eviction_hint()'s escalated directive
|
|
196
|
+
doesn't immediately re-fire on the very next retrieval
|
|
197
|
+
(UPG-REMEMBER-BANNER-FATIGUE, UPG-EVICT-ESCALATION-GATE-TOO-LOW).
|
|
198
|
+
Also arms the softer first-refire wording (see _soft_fire_pending).
|
|
199
|
+
Does not touch should_evict()'s own token/call/time state — a
|
|
200
|
+
vectr_remember doesn't reduce the actual context already retrieved.
|
|
201
|
+
"""
|
|
202
|
+
self._chunks_since_remember = 0
|
|
203
|
+
self._tokens_at_last_remember = self.total_tokens_in_session()
|
|
204
|
+
self._soft_fire_pending = True
|
|
205
|
+
|
|
206
|
+
def _tokens_since_remember(self) -> int:
|
|
207
|
+
"""Tokens tracked since the caller's last vectr_remember (or session
|
|
208
|
+
start, if never called). Companion to _chunks_since_remember —
|
|
209
|
+
see EVICTION_REMEMBER_ESCALATION_TOKENS."""
|
|
210
|
+
return self.total_tokens_in_session() - self._tokens_at_last_remember
|
|
211
|
+
|
|
212
|
+
def increment_tool_call(self) -> None:
|
|
213
|
+
"""Increment the total MCP tool call counter for this session."""
|
|
214
|
+
self._tool_call_count += 1
|
|
215
|
+
|
|
216
|
+
def increment_retrieval_call(self) -> None:
|
|
217
|
+
"""Increment the retrieval-specific call counter (search/locate/trace only)."""
|
|
218
|
+
self._retrieval_call_count += 1
|
|
219
|
+
|
|
220
|
+
def total_tokens_in_session(self) -> int:
|
|
221
|
+
return sum(c.estimated_tokens for c in self._chunks)
|
|
222
|
+
|
|
223
|
+
def should_evict(self) -> bool:
|
|
224
|
+
elapsed = time.time() - self._session_started_at
|
|
225
|
+
return (
|
|
226
|
+
self.total_tokens_in_session() >= self._threshold
|
|
227
|
+
or self._tool_call_count > self._tool_call_threshold
|
|
228
|
+
or self._retrieval_call_count > self._retrieval_call_threshold
|
|
229
|
+
or elapsed >= self._time_threshold_seconds
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def _fresh_escalation(self) -> bool:
|
|
233
|
+
"""True on the first auto-emit, then only after a MATERIAL increase since
|
|
234
|
+
the last one: another full token-threshold retrieved, `_rearm_retrieval_calls`
|
|
235
|
+
more retrieval calls, or another time-threshold window elapsed. Keeps the
|
|
236
|
+
per-response footer from repeating once pressure has already been flagged."""
|
|
237
|
+
if self._last_emit is None:
|
|
238
|
+
return True
|
|
239
|
+
tokens0, retr0, t0 = self._last_emit
|
|
240
|
+
return (
|
|
241
|
+
self.total_tokens_in_session() - tokens0 >= self._threshold
|
|
242
|
+
or self._retrieval_call_count - retr0 >= self._rearm_retrieval_calls
|
|
243
|
+
or (time.time() - t0) >= self._time_threshold_seconds
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def _tokens_since_last_hint(self) -> int:
|
|
247
|
+
"""Tokens accumulated since the last auto-hint emit (or session start)."""
|
|
248
|
+
tokens_at_last = self._last_emit[0] if self._last_emit is not None else 0
|
|
249
|
+
return self.total_tokens_in_session() - tokens_at_last
|
|
250
|
+
|
|
251
|
+
def auto_eviction_hint(self) -> str:
|
|
252
|
+
"""Gated variant for the per-response footer (UPG-7.1 / UPG-11.15).
|
|
253
|
+
|
|
254
|
+
Emits the hint only when BOTH conditions hold:
|
|
255
|
+
1. Context pressure freshly escalates (UPG-7.1 — never on every response).
|
|
256
|
+
2. Accumulated retrieved tokens since the last hint (or session start)
|
|
257
|
+
have crossed _retrieved_token_gate (UPG-11.15 — suppresses bursts of
|
|
258
|
+
small-result searches that add negligible context pressure).
|
|
259
|
+
|
|
260
|
+
The explicit ``vectr_evict_hint`` tool and the ``/v1/evict`` endpoint
|
|
261
|
+
still use ``eviction_hint()`` (ungated): an explicit ask always answers.
|
|
262
|
+
|
|
263
|
+
UPG-REMEMBER-BANNER-FATIGUE / UPG-EVICT-ESCALATION-GATE-TOO-LOW: also
|
|
264
|
+
suppressed unless BOTH enough new chunks AND enough new tokens have
|
|
265
|
+
been retrieved since the caller's last vectr_remember (or session
|
|
266
|
+
start) — a single large search can trip a chunk-only gate in one
|
|
267
|
+
burst, so both must independently clear before the gate is satisfied
|
|
268
|
+
at all. Below that, MCP dispatch's own turn-count soft nudge (or
|
|
269
|
+
nothing) takes over instead of this method's wording.
|
|
270
|
+
|
|
271
|
+
Once the gate is satisfied, the FIRST eligible fire after a
|
|
272
|
+
vectr_remember renders the softer, non-imperative form (not ACTION
|
|
273
|
+
REQUIRED); only a second-or-later eligible fire without an
|
|
274
|
+
intervening vectr_remember escalates to the imperative directive
|
|
275
|
+
(UPG-EVICT-ESCALATION-GATE-TOO-LOW) — so compliance is never
|
|
276
|
+
answered with the same harsh wording on the very next response.
|
|
277
|
+
"""
|
|
278
|
+
if not self.should_evict() or not self._fresh_escalation():
|
|
279
|
+
return ""
|
|
280
|
+
# UPG-11.15: even if should_evict() is true (e.g. retrieval_call_count
|
|
281
|
+
# tripped), don't emit if the retrieved content since the last hint is
|
|
282
|
+
# small. The per-call-count trigger fires on the 2nd retrieval call,
|
|
283
|
+
# but three 15-line methods contribute only ~100–150 tokens — far below
|
|
284
|
+
# real context pressure.
|
|
285
|
+
if self._tokens_since_last_hint() < self._retrieved_token_gate:
|
|
286
|
+
return ""
|
|
287
|
+
if (
|
|
288
|
+
self._chunks_since_remember < self._remember_escalation_chunks
|
|
289
|
+
or self._tokens_since_remember() < self._remember_escalation_tokens
|
|
290
|
+
):
|
|
291
|
+
return ""
|
|
292
|
+
escalate = not self._soft_fire_pending
|
|
293
|
+
hint = self.eviction_hint(escalated=escalate)
|
|
294
|
+
if hint:
|
|
295
|
+
self._last_emit = (
|
|
296
|
+
self.total_tokens_in_session(),
|
|
297
|
+
self._retrieval_call_count,
|
|
298
|
+
time.time(),
|
|
299
|
+
)
|
|
300
|
+
if not escalate:
|
|
301
|
+
# Soft form consumed — the next eligible fire (without an
|
|
302
|
+
# intervening vectr_remember) escalates.
|
|
303
|
+
self._soft_fire_pending = False
|
|
304
|
+
return hint
|
|
305
|
+
|
|
306
|
+
def eviction_hint(self, escalated: bool = True) -> str:
|
|
307
|
+
"""
|
|
308
|
+
Return a message listing chunks vectr can re-retrieve in <50ms.
|
|
309
|
+
Always safe to call — returns an empty hint if nothing has been retrieved
|
|
310
|
+
and no time-based pressure exists.
|
|
311
|
+
|
|
312
|
+
escalated=True (the default, used by the explicit vectr_evict_hint tool
|
|
313
|
+
and the /v1/evict endpoint — an explicit ask always gets the full
|
|
314
|
+
directive) renders the imperative "ACTION REQUIRED" wording plus a
|
|
315
|
+
trailing machine-readable ``needs_remember: true`` line a harness can
|
|
316
|
+
key off without parsing prose. escalated=False renders the identical
|
|
317
|
+
factual content (files, token count, re-fetch keys) with softer,
|
|
318
|
+
non-imperative wording and omits the needs_remember line — used by
|
|
319
|
+
auto_eviction_hint() for the first eligible re-fire after a
|
|
320
|
+
vectr_remember (UPG-EVICT-ESCALATION-GATE-TOO-LOW).
|
|
321
|
+
"""
|
|
322
|
+
if not self._chunks:
|
|
323
|
+
# No vectr-tracked chunks, but time pressure still warrants a nudge
|
|
324
|
+
elapsed = time.time() - self._session_started_at
|
|
325
|
+
if elapsed >= self._time_threshold_seconds:
|
|
326
|
+
if escalated:
|
|
327
|
+
return (
|
|
328
|
+
"─── ACTION REQUIRED ───\n"
|
|
329
|
+
"Call vectr_remember(content, tags=[...]) NOW before continuing.\n"
|
|
330
|
+
"Save: key type names, module paths, entry points, non-obvious patterns.\n"
|
|
331
|
+
"Your synthesized understanding does not persist automatically.\n"
|
|
332
|
+
"Call vectr_remember now, then continue your task.\n"
|
|
333
|
+
"needs_remember: true"
|
|
334
|
+
)
|
|
335
|
+
return (
|
|
336
|
+
"Reminder: consider calling vectr_remember(content, tags=[...]) soon.\n"
|
|
337
|
+
"Save: key type names, module paths, entry points, non-obvious patterns.\n"
|
|
338
|
+
"Your synthesized understanding does not persist automatically."
|
|
339
|
+
)
|
|
340
|
+
return ""
|
|
341
|
+
|
|
342
|
+
total_tokens = self.total_tokens_in_session()
|
|
343
|
+
by_file: dict[str, list[RetrievedChunk]] = {}
|
|
344
|
+
for c in self._chunks:
|
|
345
|
+
by_file.setdefault(c.file_path, []).append(c)
|
|
346
|
+
|
|
347
|
+
# UPG-EVICT-HINT-RECENCY: order files most-recently-retrieved-first,
|
|
348
|
+
# not by first-ever-recorded order (a dict's insertion-order iteration
|
|
349
|
+
# was an accident of implementation, not a chosen policy). The
|
|
350
|
+
# caller's active working set — what it can most usefully drop from
|
|
351
|
+
# context and re-fetch on demand — is whatever it retrieved most
|
|
352
|
+
# recently, not whatever it happened to retrieve first this session.
|
|
353
|
+
# A file's recency is the position (in self._chunks) of the LAST
|
|
354
|
+
# chunk recorded for it; since record() appends monotonically, that
|
|
355
|
+
# position doubles as a recency sequence number. Sorting descending
|
|
356
|
+
# by this unique-per-file key is deterministic; the stable sort
|
|
357
|
+
# tie-breaks by insertion order in the (practically unreachable)
|
|
358
|
+
# case of equal keys.
|
|
359
|
+
last_seq: dict[str, int] = {}
|
|
360
|
+
for i, c in enumerate(self._chunks):
|
|
361
|
+
last_seq[c.file_path] = i
|
|
362
|
+
file_items = sorted(by_file.items(), key=lambda kv: last_seq[kv[0]], reverse=True)
|
|
363
|
+
shown = file_items[:5]
|
|
364
|
+
overflow = len(file_items) - len(shown)
|
|
365
|
+
|
|
366
|
+
if escalated:
|
|
367
|
+
lines = [
|
|
368
|
+
"─── ACTION REQUIRED ───",
|
|
369
|
+
"Call vectr_remember(content, tags=[...]) NOW before continuing.",
|
|
370
|
+
"Save: key type names, module paths, entry points, non-obvious patterns.",
|
|
371
|
+
"Your synthesized understanding does not persist automatically — the output",
|
|
372
|
+
"file captures findings, not the navigational path to reach them.",
|
|
373
|
+
"",
|
|
374
|
+
]
|
|
375
|
+
else:
|
|
376
|
+
lines = [
|
|
377
|
+
"─── context recap ───",
|
|
378
|
+
"New content has been retrieved since your last vectr_remember. If you found",
|
|
379
|
+
"anything worth keeping — key type names, module paths, entry points, non-obvious",
|
|
380
|
+
"patterns — call vectr_remember(content, tags=[...]) now; otherwise continue.",
|
|
381
|
+
"",
|
|
382
|
+
]
|
|
383
|
+
lines += [
|
|
384
|
+
f"Vectr has {len(self._chunks)} retrieved chunks (~{total_tokens} tokens)"
|
|
385
|
+
" fully indexed. Drop these chunks from context — each is re-fetchable"
|
|
386
|
+
" verbatim via vectr_fetch(ids=[...]) in <50ms."
|
|
387
|
+
" Your synthesized analysis (saved via vectr_remember) is retrievable via vectr_recall.",
|
|
388
|
+
"",
|
|
389
|
+
"Files below are listed most recently retrieved first:",
|
|
390
|
+
]
|
|
391
|
+
for fpath, chunks in shown:
|
|
392
|
+
ranges = ", ".join(
|
|
393
|
+
f"lines {c.lines}" + (f" ({c.symbol_name})" if c.symbol_name else "")
|
|
394
|
+
for c in chunks
|
|
395
|
+
)
|
|
396
|
+
lines.append(f" {fpath} [{ranges}]")
|
|
397
|
+
if overflow:
|
|
398
|
+
lines.append(f" ... and {overflow} more file(s). All retrievable via vectr_search('<description>').")
|
|
399
|
+
|
|
400
|
+
# UPG-EVICT-SESSION-SCOPE: list the exact re-fetch keys additively —
|
|
401
|
+
# only for chunks whose id is a known-good vectr_fetch key (never a
|
|
402
|
+
# guessed one), capped so the hint's own token cost stays bounded.
|
|
403
|
+
# UPG-EVICT-REFETCH-KEYS-STALE: self._chunks is oldest-first with
|
|
404
|
+
# re-touched chunks moved to the end, so take the suffix and reverse —
|
|
405
|
+
# most recently retrieved first, matching the file list's stated
|
|
406
|
+
# ordering above. The old prefix slice pinned the session's OLDEST
|
|
407
|
+
# chunks here forever, rendering the identical id list in every
|
|
408
|
+
# escalated banner regardless of what was retrieved since.
|
|
409
|
+
fetch_ids = [c.chunk_id for c in self._chunks if c.chunk_id][-EVICTION_HINT_MAX_IDS:][::-1]
|
|
410
|
+
if fetch_ids:
|
|
411
|
+
id_list = ", ".join(f'"{i}"' for i in fetch_ids)
|
|
412
|
+
lines += [
|
|
413
|
+
"",
|
|
414
|
+
f"Re-fetch keys: vectr_fetch(ids=[{id_list}]) restores these verbatim.",
|
|
415
|
+
]
|
|
416
|
+
|
|
417
|
+
if escalated:
|
|
418
|
+
lines += [
|
|
419
|
+
"",
|
|
420
|
+
"Call vectr_remember now, then continue your task.",
|
|
421
|
+
"needs_remember: true",
|
|
422
|
+
]
|
|
423
|
+
return "\n".join(lines)
|
|
424
|
+
|
|
425
|
+
def clear_session(self) -> None:
|
|
426
|
+
"""Reset for a new session."""
|
|
427
|
+
self._chunks.clear()
|
|
428
|
+
self._tool_call_count = 0
|
|
429
|
+
self._retrieval_call_count = 0
|
|
430
|
+
self._session_started_at = time.time()
|
|
431
|
+
self._last_emit = None
|
|
432
|
+
self._chunks_since_remember = 0
|
|
433
|
+
self._tokens_at_last_remember = 0
|
|
434
|
+
self._soft_fire_pending = False
|
|
435
|
+
|
|
436
|
+
def as_chunk_dicts(self) -> list[dict]:
|
|
437
|
+
"""Serialisable form for snapshot storage."""
|
|
438
|
+
return [
|
|
439
|
+
{
|
|
440
|
+
"file": c.file_path,
|
|
441
|
+
"lines": c.lines,
|
|
442
|
+
"symbol": c.symbol_name,
|
|
443
|
+
"content": c.content,
|
|
444
|
+
"chunk_id": c.chunk_id,
|
|
445
|
+
}
|
|
446
|
+
for c in self._chunks
|
|
447
|
+
]
|
agent/identifier_hint.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Identifier-shape tokenizer for the vectr_search additive symbol-graph hint
|
|
3
|
+
(UPG-QUERYTYPE-REROUTE).
|
|
4
|
+
|
|
5
|
+
This replaces the deleted `agent/query_router.py` regex query-classification
|
|
6
|
+
layer. That layer classified a query's INTENT via keyword/phrase lists
|
|
7
|
+
(`\\boverride\\b`, `\\bsubclass(es)?\\b`, `\\b(dependenc(y|ies)|downstream|
|
|
8
|
+
upstream)\\b`, ...) and, on a match, silently overrode the fingerprint-derived
|
|
9
|
+
semantic weight and guessed a symbol to look up from the query's first
|
|
10
|
+
non-stopword word — an innocent NL question about "dependencies" or
|
|
11
|
+
"overriding a method" misrouted into a same-named-homonym symbol lookup with
|
|
12
|
+
no ranking tie to the query at all.
|
|
13
|
+
|
|
14
|
+
The replacement here does no intent classification whatsoever. It detects
|
|
15
|
+
token SHAPE, not meaning: CamelCase, snake_case, and dotted/qualified
|
|
16
|
+
(`Class.method`) forms are structural properties of an identifier regardless
|
|
17
|
+
of what the surrounding sentence is about. A plain word is never treated as
|
|
18
|
+
an identifier candidate even if it happens to also be a symbol name — the
|
|
19
|
+
`identifier_hint_symbols()` caller (see `app/service.py`) additionally
|
|
20
|
+
requires an EXACT symbol-graph match before anything is surfaced, so a
|
|
21
|
+
shape-detected token that doesn't resolve produces no output at all.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
|
|
27
|
+
# Combined, single regex — shape detection only, no word/keyword/phrase list:
|
|
28
|
+
# 1. dotted/qualified form e.g. "QuerySet.delete"
|
|
29
|
+
# 2. CamelCase/PascalCase e.g. "WorkspaceLock" (leading capital, an
|
|
30
|
+
# internal lower-then-upper transition — this
|
|
31
|
+
# excludes an ordinary capitalised sentence word
|
|
32
|
+
# like "Where", which has no internal transition)
|
|
33
|
+
# 3. lowerCamelCase e.g. "scheduleUpdateOnFiber" (leading
|
|
34
|
+
# lowercase, at least two lower/digit chars, then
|
|
35
|
+
# an internal lower-then-upper transition — the
|
|
36
|
+
# two-char minimum before the transition is what
|
|
37
|
+
# excludes a single-letter-prefix brand word like
|
|
38
|
+
# "iPhone"/"eBay", not shape detection of any
|
|
39
|
+
# particular word)
|
|
40
|
+
# 4. snake_case e.g. "acquire_lock" (contains an underscore)
|
|
41
|
+
# Order matters: the dotted alternative is tried first so "Class.method" is
|
|
42
|
+
# captured as one token rather than splitting at the dot. ALLCAPS tokens
|
|
43
|
+
# ("HTML", "API") match none of these — the CamelCase/lowerCamelCase
|
|
44
|
+
# alternatives both require an internal LOWERCASE run before the case
|
|
45
|
+
# transition, which an all-uppercase run never has.
|
|
46
|
+
_IDENTIFIER_TOKEN_RE = re.compile(
|
|
47
|
+
r"[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*"
|
|
48
|
+
r"|[A-Z][a-z0-9]+[A-Z][A-Za-z0-9]*"
|
|
49
|
+
r"|[a-z][a-z0-9]+[A-Z][A-Za-z0-9]*"
|
|
50
|
+
r"|[A-Za-z_][A-Za-z0-9]*_[A-Za-z0-9_]*"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def extract_identifier_tokens(query: str) -> list[str]:
|
|
55
|
+
"""Extract identifier-SHAPED tokens from a raw query string.
|
|
56
|
+
|
|
57
|
+
Returns candidates in order of first appearance, deduplicated. Empty for
|
|
58
|
+
a query built entirely of plain words (no CamelCase/snake_case/dotted
|
|
59
|
+
signal) — the common case for ordinary natural-language questions.
|
|
60
|
+
"""
|
|
61
|
+
seen: set[str] = set()
|
|
62
|
+
tokens: list[str] = []
|
|
63
|
+
for m in _IDENTIFIER_TOKEN_RE.finditer(query):
|
|
64
|
+
tok = m.group(0)
|
|
65
|
+
if tok not in seen:
|
|
66
|
+
seen.add(tok)
|
|
67
|
+
tokens.append(tok)
|
|
68
|
+
return tokens
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""AST-aware code chunking and embedding pipeline.
|
|
2
|
+
|
|
3
|
+
Package layout:
|
|
4
|
+
_constants.py — LANG_BY_EXT, EXCLUDED_DIRS, batch-size constants
|
|
5
|
+
_types.py — CodeChunk dataclass, EmbedProvider protocol, embed provider classes
|
|
6
|
+
_chunking.py — tree-sitter parsers, chunk collection, fallback window chunker
|
|
7
|
+
_core.py — CodeIndexer orchestration class
|
|
8
|
+
|
|
9
|
+
All names that existed on the flat agent/indexer.py module are re-exported here
|
|
10
|
+
so every existing import site keeps working unchanged:
|
|
11
|
+
from agent.indexer import CodeIndexer (watcher, searcher, service, tests)
|
|
12
|
+
from agent.indexer import chunk_file (tests)
|
|
13
|
+
from agent.indexer import LANG_BY_EXT (watcher, cartographer, symbol_graph)
|
|
14
|
+
from agent.indexer import EXCLUDED_DIRS (watcher, cartographer)
|
|
15
|
+
from agent.indexer import get_embed_provider (tests — patched at this namespace)
|
|
16
|
+
from agent.indexer import c_symbol_name (symbol_graph)
|
|
17
|
+
from agent.indexer import _get_parser (symbol_graph)
|
|
18
|
+
from agent.indexer import _parser_language_for (symbol_graph — UPG-JSFLOW-SYMBOLS)
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
# Public constants
|
|
23
|
+
from agent.indexer._constants import (
|
|
24
|
+
LANG_BY_EXT,
|
|
25
|
+
EXCLUDED_DIRS,
|
|
26
|
+
_FILE_BATCH_SIZE,
|
|
27
|
+
_EMBED_BATCH_SIZE,
|
|
28
|
+
_UPSERT_BATCH_SIZE,
|
|
29
|
+
_CHUNK_WORKERS,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Public types / embed providers
|
|
33
|
+
from agent.indexer._types import (
|
|
34
|
+
CodeChunk,
|
|
35
|
+
EmbedProvider,
|
|
36
|
+
LocalEmbedProvider,
|
|
37
|
+
VoyageEmbedProvider,
|
|
38
|
+
OpenAIEmbedProvider,
|
|
39
|
+
get_embed_provider,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Config-sourced tunables re-exported so `import agent.indexer as m; m._MAX_CHUNK_LINES`
|
|
43
|
+
# continues to work (test_config_loader asserts these are the same objects as config.py).
|
|
44
|
+
from agent.config import (
|
|
45
|
+
INDEXING_MAX_CHUNK_LINES as _MAX_CHUNK_LINES,
|
|
46
|
+
INDEXING_CLASS_HEADER_LINES as _CLASS_HEADER_LINES,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Public chunking symbols (tests import chunk_file; symbol_graph imports
|
|
50
|
+
# _get_parser and c_symbol_name directly from agent.indexer)
|
|
51
|
+
from agent.indexer._chunking import (
|
|
52
|
+
chunk_file,
|
|
53
|
+
c_symbol_name,
|
|
54
|
+
_get_parser,
|
|
55
|
+
_PARSER_CACHE,
|
|
56
|
+
_CLASS_NODE_TYPES,
|
|
57
|
+
_CHUNK_NODE_TYPES,
|
|
58
|
+
_SYMBOL_FIELD,
|
|
59
|
+
_C_TYPE_NAME_NODES,
|
|
60
|
+
_c_declarator_name,
|
|
61
|
+
_extract_symbol_name,
|
|
62
|
+
_get_leading_comments,
|
|
63
|
+
_collect_chunks_ast,
|
|
64
|
+
_fallback_window_chunks,
|
|
65
|
+
_chunk_markdown,
|
|
66
|
+
_postprocess_chunks,
|
|
67
|
+
is_flow_javascript,
|
|
68
|
+
_parser_language_for,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Public indexer class
|
|
72
|
+
from agent.indexer._core import CodeIndexer
|
|
73
|
+
|
|
74
|
+
__all__ = [
|
|
75
|
+
# Config-sourced tunables (re-exported for test_config_loader)
|
|
76
|
+
"_MAX_CHUNK_LINES",
|
|
77
|
+
"_CLASS_HEADER_LINES",
|
|
78
|
+
# Constants
|
|
79
|
+
"LANG_BY_EXT",
|
|
80
|
+
"EXCLUDED_DIRS",
|
|
81
|
+
"_FILE_BATCH_SIZE",
|
|
82
|
+
"_EMBED_BATCH_SIZE",
|
|
83
|
+
"_UPSERT_BATCH_SIZE",
|
|
84
|
+
"_CHUNK_WORKERS",
|
|
85
|
+
# Types / providers
|
|
86
|
+
"CodeChunk",
|
|
87
|
+
"EmbedProvider",
|
|
88
|
+
"LocalEmbedProvider",
|
|
89
|
+
"VoyageEmbedProvider",
|
|
90
|
+
"OpenAIEmbedProvider",
|
|
91
|
+
"get_embed_provider",
|
|
92
|
+
# Chunking
|
|
93
|
+
"chunk_file",
|
|
94
|
+
"c_symbol_name",
|
|
95
|
+
"_get_parser",
|
|
96
|
+
"_PARSER_CACHE",
|
|
97
|
+
"_CLASS_NODE_TYPES",
|
|
98
|
+
"_CHUNK_NODE_TYPES",
|
|
99
|
+
"_SYMBOL_FIELD",
|
|
100
|
+
"_C_TYPE_NAME_NODES",
|
|
101
|
+
"_c_declarator_name",
|
|
102
|
+
"_extract_symbol_name",
|
|
103
|
+
"_get_leading_comments",
|
|
104
|
+
"_collect_chunks_ast",
|
|
105
|
+
"_fallback_window_chunks",
|
|
106
|
+
"_chunk_markdown",
|
|
107
|
+
"_postprocess_chunks",
|
|
108
|
+
"is_flow_javascript",
|
|
109
|
+
"_parser_language_for",
|
|
110
|
+
# Core
|
|
111
|
+
"CodeIndexer",
|
|
112
|
+
]
|