Skip to content

Commit db22230

Browse files
Merge pull request #1753 from AxiomCodeAI/loop/to-p0-presearch-hint-noise
hooks: pre-search directive only names what this search is for, once per session
2 parents 12d1574 + 33e5952 commit db22230

5 files changed

Lines changed: 263 additions & 218 deletions

File tree

‎plugins/axiomcode/hooks/direct.py‎

Lines changed: 137 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -15,172 +15,177 @@
1515
· SILENT WHEN THE AGENT IS ALREADY DOING IT. A Bash call that IS an axiomcode verb gets nothing.
1616
· NEVER BLOCKS. additionalContext, exit 0. The agent keeps its own judgement; the point is that the
1717
judgement is made with the option in view, not that the option wins.
18-
· ONCE PER SESSION. Said in full before the first search, then never again. It used to repeat as one line
19-
before every later Read, Grep and Bash, and in a measured run those lines were seven of the plugin's
20-
injections, re-read on every turn after, for an agent that had already called the graph twice. A
21-
reminder only has to be visible once; after that it is a context tax on every turn.
22-
· ONLY WHERE THE CHOICE IS. A Bash call that searches or reads source competes with the graph; `git`,
23-
`ls`, a build or a test run does not, and a directive in front of one is noise.
18+
· ONCE PER SESSION, WHEREVER THE SESSION GOES. Said once, then never again. It used to repeat as one line before
19+
every later Read, Grep and Bash; then it was stamped once per session PER REPOSITORY, inside that repository's
20+
.axiomcode/, so an agent moving between a worktree, its fixtures and a corpus app heard the full text in each,
21+
and one whose .axiomcode/ it could not write heard it before every search. The stamp now lives in the temp
22+
directory, keyed by the session alone.
23+
· ONLY WHEN IT CAN NAME WHAT THIS SEARCH IS ABOUT. The generic text was spoken before the first search whatever
24+
it searched for, and a feed audit found it acted on 0% of the time: advice about "a name" in front of a search
25+
for `TODO` or `'use strict'` is not advice about this search. It now speaks only before a search whose pattern
26+
names a class, function or method the graph declares, names it with where it is, and gives the one call that
27+
answers what the grep is asking. A Read of a file already chosen and a Glob for files are not that question.
28+
· ONLY WHERE THE CHOICE IS. A Bash call that searches source competes with the graph; `git`, `ls`, a build, a
29+
test run, or a grep filtering another command's output does not, and a directive in front of one is noise.
2430
"""
25-
import json, os, re, shlex, sys
31+
import hashlib, json, os, re, sys
2632
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
2733
import _host, _where
2834

29-
SRC = _where.SOURCE_EXT # one table for every hook (_where.py)
30-
# the shell commands that are the search or read this directive competes with
31-
SEARCH = re.compile(r'(^|[;&|(]\s*|\s)(grep|egrep|rg|ag|ack|git\s+grep|find|fd|cat|head|tail|sed|awk|less)\b')
32-
# words a search pattern is made of that name no declaration (a keyword written into the pattern to anchor it)
33-
KEYWORDS = {'def', 'class', 'public', 'private', 'protected', 'static', 'final', 'void', 'function', 'import', 'from',
34-
'return', 'new', 'async', 'await', 'self', 'this', 'interface', 'struct', 'record', 'override', 'virtual',
35-
'const', 'let', 'var', 'string', 'int', 'bool', 'true', 'false', 'null', 'None', 'True', 'False', 'package',
36-
'namespace', 'using', 'extends', 'implements'}
37-
CALLABLE = ('method', 'function', 'constructor')
38-
39-
# The MCP tools are named first and the shell verbs second (#1425): `axiomcode install` pre-approves only the tools,
40-
# so an agent that takes the first spelling it reads should be taking the one it is already allowed to call.
41-
# Short on purpose: it is read once, and 13 generic lines were re-read on every later turn.
42-
FULL = (
43-
"graph: this repository has a resolved call graph; ask it before a raw search. axiomcode_impact targets=[<name>]:\n"
44-
" who uses a declaration and what a change breaks, with callers that never spell the name (an interface, an override,\n"
45-
" a callback, DI). axiomcode_path: how A reaches B. axiomcode_context: where a task lands. axiomcode_changed /\n"
46-
" axiomcode_test_impact: what an edit moved, which tests reach it. Shell: `axiomcode impact <name>`, `axiomcode path\n"
47-
" <A> <B>`, `axiomcode context \"<task>\"`, `axiomcode changed`, `axiomcode test-impact`. Pass this to a subagent."
48-
)
49-
50-
# after a line that already names the verb for THIS search, the rest of the menu in one line
51-
REST = (" Also: axiomcode_path (how A reaches B), axiomcode_context (a task in words), axiomcode_test_impact (the tests an"
52-
" edit reaches); in a shell `axiomcode <verb>`. Pass this to a subagent.")
53-
54-
55-
def _stamp(cwd, sid):
56-
"""ONCE PER SESSION, not once per (session, repository): keyed on the session in the temp directory, so an agent
57-
that moves between repositories (a dev copy, a second service) hears it the first time only. A session with no id
58-
keeps the old per-repository stamp, since every such session would otherwise share one."""
59-
if not sid:
60-
return os.path.join(cwd, '.axiomcode', '.directed-x')
35+
# the shell commands that are the search this directive competes with, and the pattern each one searches for
36+
SEARCH = re.compile(r'(^|[;&|(]\s*|\s)(grep|egrep|rg|ag|ack|git\s+grep)\b')
37+
PATTERN = re.compile(r'\b(?:grep|egrep|rg|ag|ack|git\s+grep)\b((?:\s+-[-\w=]+)*)\s+(?:-e\s+)?([\'"]?)(.+?)\2(?:\s|$)')
38+
# the declarations a search can be asking about: a callable or a type. A field, a constant or a local shares its name
39+
# with too much prose and config to be the thing a grep for that word is after.
40+
KINDS = ('class', 'interface', 'enum', 'record', 'struct', 'trait', 'function', 'method', 'constructor')
41+
SPREAD = 5
42+
COMMON = re.compile(r'(the|and|for|new|return|null|true|false|this|self|void|int|String|public|private|static|import|export|const|function|class|def)')
43+
44+
45+
def stamp_path(sid, cwd=''):
46+
"""one file per session, outside every repository: a repository's .axiomcode/ is per tree, gets rebuilt, and is not
47+
always writable, and each of those made the directive say itself again. A host that sends no session id is keyed
48+
on the directory it runs in instead, so every such session does not share one stamp and hear it once, ever."""
6149
tmp = os.environ.get('TMPDIR') or os.environ.get('TEMP') or os.environ.get('TMP') or ('/tmp' if os.name != 'nt' else os.path.expanduser('~'))
62-
return os.path.join(tmp, 'axiomcode-hooks', 'directed-' + (re.sub(r'[^\w.-]', '_', str(sid))[:80] or 'x'))
50+
key = str(sid) if sid else 'cwd-' + hashlib.sha1(os.path.realpath(cwd or '.').encode()).hexdigest()[:16]
51+
return os.path.join(tmp, 'axiomcode-hooks', 'directed-' + (re.sub(r'[^\w.-]', '_', key)[:80] or 'x'))
6352

6453

65-
def _pattern(tool, inp):
66-
"""the text a search looks for: Grep's pattern, or the first argument after grep / rg / ag / ack in a shell command"""
67-
if tool == 'Grep':
68-
return str(inp.get('pattern') or '')
69-
if tool == 'Bash':
70-
try: toks = shlex.split(str(inp.get('command') or ''))
71-
except ValueError: toks = str(inp.get('command') or '').split()
72-
for i, t in enumerate(toks):
73-
if t in ('grep', 'egrep', 'rg', 'ag', 'ack'):
74-
j = i + 1
75-
while j < len(toks) and toks[j].startswith('-'):
76-
if toks[j] in ('-e', '--regexp'): j += 1; break
77-
j += 2 if toks[j] in ('-f', '-A', '-B', '-C', '-m', '--include', '--exclude', '-g', '-t', '--type') else 1
78-
if j < len(toks): return toks[j]
79-
return ''
80-
81-
82-
def specific(cwd, tool, inp, fp):
83-
"""ONE line naming the verb that answers THIS search, when the graph has what it names: a Grep for a declared name
84-
gets `impact` on that declaration, a Read of an indexed file gets `context` scoped to it. '' when the graph has
85-
nothing to say about it (the generic block then stands alone)."""
86-
import sqlite3
87-
db = _where.graph_db(cwd, fp or None)
88-
if not os.path.isfile(db):
89-
return ''
54+
def mark(stamp):
9055
try:
91-
con = sqlite3.connect(f'file:{db}?mode=ro', uri=True)
92-
except sqlite3.Error:
93-
return ''
56+
os.makedirs(os.path.dirname(stamp), exist_ok=True)
57+
open(stamp, 'w').close()
58+
except Exception:
59+
pass
60+
61+
62+
def identifiers(pat):
63+
"""the names a search pattern spells: every branch of an alternation, regex syntax stripped, at most 6"""
64+
out = []
65+
for br in re.split(r'\\\||(?<!\\)\|', str(pat or '')):
66+
for m in re.finditer(r'(?<![\w\\])[A-Za-z_]\w{2,}', br):
67+
n = m.group(0)
68+
if COMMON.fullmatch(n) or re.fullmatch(r'[A-Z0-9_]+', n) or n in out:
69+
continue # a keyword or a CONSTANT_SHAPED word is not a callable's name
70+
# a plain lowercase word (`once`, `path`, `session`) is as likely prose or a flag as a name, unless THAT
71+
# word is written as code: `locate\(`, `def locate`, `.locate`, `function render`. Code elsewhere in the
72+
# pattern does not count: in `expr_child("client"` the name is expr_child, and client is a string.
73+
if n.islower() and '_' not in n and not (
74+
re.match(r'\\?\(', br[m.end():])
75+
or re.search(r'(\b(def|function|fn|func|void|class)\s+|\\?\.|::|->)$', br[:m.start()])):
76+
continue
77+
out.append(n)
78+
return out[:6]
79+
80+
81+
def declared(root, names, scope=()):
82+
"""[(name, display, file, line)] for the names a graph in `root` declares as a callable or a type: the declaration
83+
inside the searched files or directories (`scope`, relative to root) first, then the first in the repository. A
84+
name declared in more than SPREAD places (`get`, `run`, `handle`) says nothing about which one this search is for,
85+
and naming one of them is a guess, so it is left out."""
86+
import sqlite3
87+
ax = os.path.join(root, '.axiomcode')
88+
dbs = [os.path.join(ax, 'out', 'graph.sqlite')]
9489
try:
95-
pat = _pattern(tool, inp)
96-
if pat:
97-
for w in re.findall(r'[A-Za-z_]\w{2,}', pat):
98-
if w in KEYWORDS:
90+
dbs += [os.path.join(ax, 'lang', l, 'out', 'graph.sqlite') for l in sorted(os.listdir(os.path.join(ax, 'lang')))]
91+
except OSError:
92+
pass
93+
found = {}
94+
ph = ','.join('?' * len(KINDS))
95+
for db in dbs:
96+
if len(found) == len(names) or not os.path.isfile(db):
97+
continue
98+
try:
99+
con = sqlite3.connect(f'file:{db}?mode=ro', uri=True)
100+
for n in names:
101+
if n in found:
99102
continue
100-
rows = con.execute("SELECT display, kind, file, line FROM symbols WHERE name = ? AND kind != 'module' "
101-
"ORDER BY is_test, (kind IN ('method','function','constructor')) DESC, file, line LIMIT 4", (w,)).fetchall()
102-
if not rows:
103+
rows = con.execute(f"SELECT display, file, line FROM symbols WHERE name = ? AND kind IN ({ph}) "
104+
f"ORDER BY is_test, file, line LIMIT {SPREAD + 1}", (n, *KINDS)).fetchall()
105+
if not rows or len(rows) > SPREAD:
103106
continue
104-
disp, kind, f, l = rows[0]
105-
target = disp if len(rows) == 1 else w
106-
what = 'Who calls it and what a change breaks' if kind in CALLABLE else \
107-
('Who reads and writes it' if kind in ('field', 'const', 'variable', 'property') else 'Who uses it, its subtypes, what a change breaks')
108-
where = f"{disp}, {f}:{l}" if len(rows) == 1 else f"{len(rows) if len(rows) < 4 else '4+'} declarations, first {disp} {f}:{l}"
109-
return (f"graph: `{w}` is declared here ({where}). {what}: axiomcode_impact targets=[\"{target}\"]"
110-
f" (`axiomcode impact {target}`); a grep misses the callers that never spell it.")
111-
if tool in ('Read', 'Bash') and fp:
112-
rel = os.path.relpath(os.path.realpath(fp), os.path.realpath(cwd)).replace(os.sep, '/')
113-
n = con.execute("SELECT count(*) FROM symbols WHERE file = ? AND kind IN ('method','function','constructor')", (rel,)).fetchone()[0]
114-
if n:
115-
return (f"graph: {rel} has {n} callable(s) in the graph. How it works: axiomcode_context task=\"<question>\""
116-
f" in_path=\"{rel}\" source=True; one of them: axiomcode_impact targets=[\"{rel}:<line>\"].")
117-
except sqlite3.Error:
118-
pass
119-
finally:
120-
con.close()
121-
return ''
107+
inside = [r for r in rows if any(r[1] == s or r[1].startswith(s.rstrip('/') + '/') for s in scope)]
108+
r = (inside or rows)[0]
109+
found[n] = (n, r[0], r[1], r[2])
110+
con.close()
111+
except Exception:
112+
continue
113+
return [found[n] for n in names if n in found]
114+
115+
116+
def directive(hits):
117+
"""what to say before a search for these declarations: each named, with the call that answers what the grep asks"""
118+
first = hits[0]
119+
at = f"{first[2]}:{first[3]}"
120+
named = ', '.join(f"`{h[1]}` ({h[2]}:{h[3]})" for h in hits[:3])
121+
# short on purpose: it is read once and then re-read on every later turn
122+
return (
123+
f"graph: this search is for {named}. Who calls it and what a change breaks,\n"
124+
f" with the callers that never spell the name (an interface, an override, a callback, DI):\n"
125+
f" axiomcode_impact targets=[\"{at}\"] (`axiomcode impact {at}`). Also axiomcode_path (how A reaches B,\n"
126+
f" `axiomcode path A B`), axiomcode_context (a task in words, `axiomcode context \"<task>\"`). Said once this session."
127+
)
122128

123129

124130
def main():
125131
ev = _host.read()
126132
tool = ev.get('tool_name') or ''
127133
inp = ev.get('tool_input') or {}
128-
# the repository whose graph would answer: the one above the file or directory this tool is about to touch, not
129-
# the session's working directory, which in 308 of 309 measured sessions held no graph (_where.py)
130-
cwd = _where.locate(tool, inp, ev.get('cwd') or os.getcwd(), ev.get('session_id'))
131-
if not cwd:
132-
sys.exit(0)
133-
134-
stamp = _stamp(cwd, ev.get('session_id'))
134+
stamp = stamp_path(ev.get('session_id'), ev.get('cwd') or os.getcwd())
135135

136136
# the agent is already reaching for the graph -- saying it again is the nag this is trying not to be,
137-
# and once it has, the directive has nothing left to say this session
137+
# and once it has, the directive has nothing left to say this session, in any repository
138138
if 'axiomcode' in tool or (tool == 'Bash' and 'axiomcode' in (inp.get('command') or '')):
139-
try:
140-
os.makedirs(os.path.dirname(stamp), exist_ok=True)
141-
open(stamp, 'w').close()
142-
except Exception:
143-
pass
139+
mark(stamp)
140+
sys.exit(0)
141+
if os.path.exists(stamp):
144142
sys.exit(0)
145143

146-
# a Read of something that is not source is not the decision this is about (a log, a lockfile, a README)
147-
if tool == 'Read':
148-
fp = inp.get('file_path') or ''
149-
if not _where.is_source(_where._abs(fp, ev.get('cwd') or os.getcwd())):
144+
# only a search has a pattern that can name what it is looking for; a Read of a chosen file or a Glob does not
145+
here = ev.get('cwd') or os.getcwd()
146+
if tool == 'Grep':
147+
pat = inp.get('pattern') or ''
148+
paths = [inp['path']] if inp.get('path') else []
149+
elif tool == 'Bash':
150+
cmd = inp.get('command') or ''
151+
m = SEARCH.search(cmd) and PATTERN.search(cmd)
152+
if not m:
150153
sys.exit(0)
151-
152-
if tool == 'Bash' and not SEARCH.search(inp.get('command') or ''):
153-
sys.exit(0)
154-
# the same rule as a Read: a shell read or search whose every file is not source (`cat NOTES.md`, `tail run.log`)
155-
# is not the decision this is about. Of the 3 times this hook spoke in 190 measured agents, one was a `cat` of a
156-
# Markdown file. A search with no file argument (a directory, or none) still searches source.
157-
if tool == 'Bash':
158-
_, paths = _where.bash_where(inp.get('command'), ev.get('cwd') or os.getcwd())
154+
# after a `|` a grep filters another command's output (`npm test | grep FAIL`), which is not a code search
155+
seps = re.findall(r'\|\||&&|;|\n|\|', cmd[:m.start()])
156+
if seps and seps[-1] == '|':
157+
sys.exit(0)
158+
# a search whose every file is not source (`grep x NOTES.md`, `rg y run.log`) is not the decision this is about
159+
_, paths = _where.bash_where(cmd, here)
159160
files = [p for p in paths if os.path.isfile(p)]
160161
if files and not any(_where.is_source(p) for p in files):
161162
sys.exit(0)
163+
pat = m.group(3)
164+
else:
165+
sys.exit(0)
162166

163-
if os.path.exists(stamp):
167+
names = identifiers(pat)
168+
if not names:
169+
sys.exit(0)
170+
# the repository whose graph would answer: the one above the file or directory this tool is about to touch, not
171+
# the session's working directory, which in 308 of 309 measured sessions held no graph (_where.py)
172+
cwd = _where.locate(tool, inp, here, ev.get('session_id'))
173+
if not cwd:
174+
sys.exit(0)
175+
real = os.path.realpath(cwd)
176+
scope = [os.path.relpath(os.path.realpath(os.path.join(here, p)), real) for p in paths]
177+
hits = declared(cwd, names, [s for s in scope if s != '.' and not s.startswith('..')])
178+
if not hits:
164179
sys.exit(0)
165-
try:
166-
os.makedirs(os.path.dirname(stamp), exist_ok=True)
167-
open(stamp, 'w').close()
168-
except Exception:
169-
pass
170180

171-
fp = _where._abs(inp['file_path'], ev.get('cwd') or os.getcwd()) if tool == 'Read' and inp.get('file_path') else \
172-
(next((p for p in files if _where.is_source(p)), '') if tool == 'Bash' else '')
173-
try:
174-
one = specific(cwd, tool, inp, fp)
175-
except Exception:
176-
one = ''
177-
text = (one + '\n' + REST) if one else FULL
181+
mark(stamp)
182+
text = directive(hits)
178183
# stream-json does not carry additionalContext, so a run cannot show from its transcript that this
179184
# fired or what it said -- and #1100 is a question about exactly that. enrich.py already logs itself
180185
# next to the graph for the same reason; this writes the same file, so one reader sees both halves.
181186
try:
182187
with open(os.path.join(cwd, '.axiomcode', 'hooks.jsonl'), 'a') as f:
183-
f.write(json.dumps({'hook': 'direct', 'tool': tool, 'chars': len(text),
188+
f.write(json.dumps({'hook': 'direct', 'tool': tool, 'chars': len(text), 'named': [h[1] for h in hits],
184189
'input': {k: v for k, v in inp.items()
185190
if k in ('file_path', 'pattern', 'command')}}) + '\n')
186191
except OSError:

0 commit comments

Comments
 (0)