Skip to content

Commit 566a041

Browse files
Merge branch 'main' into feat/prime-agent-integration
2 parents 3ebf0fb + 230573d commit 566a041

14 files changed

Lines changed: 1424 additions & 76 deletions

engraphis/core/engine.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2175,15 +2175,20 @@ def append_visible_neighbors(
21752175
for sim, rec in extra_neighbors:
21762176
if rec.id not in known_ids:
21772177
neighbors.append((sim, rec))
2178-
# Bi-temporal backfill only: anchored writes (valid_at pinned AND a
2179-
# subject_key is present) assert explicit chain membership and may
2180-
# supersede a live neighbour even when prose alone would suggest two
2181-
# coexisting facts. Other valid_at-pinned writes (e.g. scheduled
2182-
# future writes) stay on the present-time veto contract.
2178+
# Bi-temporal backfill: any anchored write (valid_at pinned to a
2179+
# past or present timestamp) asserts explicit chain membership and
2180+
# may supersede a live neighbour even when prose alone would
2181+
# suggest two coexisting facts. The previous contract also
2182+
# required a subject_key, which silently dropped unkeyed
2183+
# historical backfills (e.g. "Customer alpha default admin user
2184+
# is root" at t=1000 followed by "Customer beta default admin
2185+
# user is admin" at t=3000); both facts should stay live under
2186+
# the bi-temporal record. Scheduled future writes
2187+
# (valid_at > now) stay on the present-time veto contract.
21832188
decision = resolve(
21842189
text, neighbors, subject_key=subject_key, claim_kind=claim_kind,
21852190
candidate_content=content,
2186-
temporal_splice=valid_at is not None and bool(subject_key),
2191+
temporal_splice=bool(subject_key) and valid_at is not None and valid_at <= now_ts(),
21872192
)
21882193
# Repair trigger: when the resolver cannot safely supersede (INVALIDATE/NOOP),
21892194
# surface a genuine high-severity contradiction as a persisted relation instead

engraphis/core/resolve.py

Lines changed: 321 additions & 4 deletions
Large diffs are not rendered by default.

eval/datasets/resolver_reworded_corrections.jsonl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@
3434
{"id": "rc34", "neighbor": "The user agent is engraphis/1.0.", "candidate": "The user agent is engraphis/2.0.", "expected": "invalidate", "subject_hint": "user agent"}
3535
{"id": "rc35", "neighbor": "The webhook secret rotates every 30 days.", "candidate": "The webhook secret rotates every 90 days.", "expected": "invalidate", "subject_hint": "webhook secret"}
3636
{"id": "rc36", "neighbor": "API tokens expire after 24 hours.", "candidate": "API tokens expire after 7 days now.", "expected": "invalidate", "subject_hint": "API token expiry"}
37-
{"id": "df01", "neighbor": "The production API uses Redis caching for user sessions.", "candidate": "The production API now uses three replicas for high availability.", "expected": "add", "subject_hint": "API infra (different facts)"}
38-
{"id": "df02", "neighbor": "The docs cover the REST interface.", "candidate": "We migrated the docs to cover the GraphQL interface.", "expected": "add", "subject_hint": "docs interface (different facts)"}
37+
{"id": "df01", "neighbor": "The production API uses Redis caching for user sessions.", "candidate": "The production API now uses three replicas for high availability.", "expected": "add", "subject_hint": "API backing infrastructure (different facts)"}
38+
{"id": "df02", "neighbor": "The docs cover the REST interface.", "candidate": "We migrated the docs to cover the GraphQL interface.", "expected": "add", "subject_hint": "docs coverage (different facts)"}
3939
{"id": "df03", "neighbor": "CI runs on ProviderA with 4 workers.", "candidate": "We switched CI to run on ProviderB with 8 workers.", "expected": "add", "subject_hint": "CI infra (different facts)"}
4040
{"id": "df04", "neighbor": "The staging database holds 300 connections in production environment.", "candidate": "The production database holds 300 connections in staging environment.", "expected": "add", "subject_hint": "staging/production (env conflict)"}
4141
{"id": "df05", "neighbor": "Production API timeout is 30 seconds.", "candidate": "Production API timeout increased to 90 seconds.", "expected": "invalidate", "subject_hint": "API timeout (value swap)"}

eval/resolver_reworded_corrections.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
python -m eval.resolver_reworded_corrections
1010
1111
The dataset ships at ``eval/datasets/resolver_reworded_corrections.jsonl``
12-
and contains 36 positive (reworded-correction) pairs and 8 negative
12+
and contains 38 positive (reworded-correction) pairs and 6 negative
1313
(distinct-fact / env-conflict) pairs. Each row is::
1414
1515
{"id", "neighbor", "candidate", "expected", "subject_hint"}
@@ -117,10 +117,17 @@ def main(argv: list[str] | None = None) -> int:
117117
parser.add_argument(
118118
"--strict",
119119
action="store_true",
120-
help="Exit non-zero if any positive is missed or any negative is "
121-
"false-invalidated. The default is to report and exit 0 so this "
122-
"script can be run in CI as an audit log without flaking on "
123-
"regressions; use --strict to gate the build.",
120+
help="(Deprecated, now the default.) Exit non-zero if any positive is "
121+
"missed or any negative is false-invalidated. The default mode is "
122+
"strict so the eval can be run in CI as an audit log without flaking "
123+
"on regressions.",
124+
)
125+
parser.add_argument(
126+
"--audit-only",
127+
action="store_true",
128+
help="Report and exit 0 even on labeled regressions. Use this only "
129+
"for ad-hoc inspection where the eval is the audit log; CI must "
130+
"not pass --audit-only.",
124131
)
125132
args = parser.parse_args(argv)
126133

@@ -143,7 +150,11 @@ def main(argv: list[str] | None = None) -> int:
143150
print(f" missed corrections: {summary['missed_correction_ids']}")
144151
if summary["false_invalidation_ids"]:
145152
print(f" false invalidations: {summary['false_invalidation_ids']}")
146-
if args.strict and (superseded < positives or false_inv > 0):
153+
# Default: strict — labeled regressions fail the run. CI must invoke
154+
# this script with no flags so the build gates on labeled quality.
155+
if args.audit_only:
156+
return 0
157+
if superseded < positives or false_inv > 0:
147158
return 1
148159
return 0
149160

integrations/commandcode/session_start_hook.py

Lines changed: 129 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,17 @@
1212
import time
1313
import urllib.request
1414

15-
MCP_URL = os.environ.get("ENGRAPHIS_MCP_URL", "http://127.0.0.1:8711/mcp")
16-
BUDGET_SECONDS = float(os.environ.get("ENGRAPHIS_HOOK_BUDGET_S", "4.0"))
17-
MAX_CONTEXT_CHARS = int(os.environ.get("ENGRAPHIS_HOOK_MAX_CHARS", "1500"))
15+
MCP_URL_DEFAULT = "http://127.0.0.1:8711/mcp"
16+
BUDGET_SECONDS_DEFAULT = 4.0
17+
MAX_CONTEXT_CHARS_DEFAULT = 1500
18+
# Backwards-compatible aliases. The module-level constants previously
19+
# crashed import when these env vars held malformed values; both are now
20+
# resolved lazily inside main() so the hook keeps its fail-open
21+
# contract. Tests and external callers that referenced the old names
22+
# keep working.
23+
MCP_URL = MCP_URL_DEFAULT
24+
BUDGET_SECONDS = BUDGET_SECONDS_DEFAULT
25+
MAX_CONTEXT_CHARS = MAX_CONTEXT_CHARS_DEFAULT
1826
CONTEXT_HEADER = (
1927
"Durable memory (engraphis, workspace {workspace}) relevant to this repo:\n"
2028
)
@@ -24,8 +32,45 @@
2432
OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
2533

2634

27-
def post(url, payload, timeout):
28-
"""POST one JSON-RPC message; return its decoded JSON or SSE response."""
35+
def _env_float(name: str, default: float) -> float:
36+
"""Parse an env-var as float, falling back on any conversion error.
37+
38+
The conversion happens inside the fail-open boundary so a malformed
39+
ENGRAPHIS_HOOK_BUDGET_S cannot crash the module at import time and
40+
cause every SessionStart to fail.
41+
"""
42+
raw = os.environ.get(name)
43+
if raw is None or not raw.strip():
44+
return default
45+
try:
46+
return float(raw)
47+
except ValueError:
48+
return default
49+
50+
51+
def _env_int(name: str, default: int) -> int:
52+
raw = os.environ.get(name)
53+
if raw is None or not raw.strip():
54+
return default
55+
try:
56+
return int(raw)
57+
except ValueError:
58+
return default
59+
60+
# Header name the MCP spec uses for the stateful session id. The bundled
61+
# dashboard /mcp endpoint issues one on initialize and rejects subsequent
62+
# requests that omit it; stateless servers ignore it.
63+
MCP_SESSION_HEADER = "Mcp-Session-Id"
64+
65+
66+
def post(url, payload, timeout, session_id=None):
67+
"""POST one JSON-RPC message; return (decoded_body, response_session_id).
68+
69+
The response_session_id is the Mcp-Session-Id returned by the server (or
70+
echoed from the request if the server didn't issue a new one) so the
71+
caller can thread the same value into subsequent requests on a
72+
stateful transport.
73+
"""
2974
request = urllib.request.Request(
3075
url,
3176
data=json.dumps(payload).encode("utf-8"),
@@ -35,10 +80,17 @@ def post(url, payload, timeout):
3580
},
3681
method="POST",
3782
)
83+
if session_id:
84+
# State transports (the dashboard /mcp endpoint in particular) reject
85+
# requests that arrive without the session id they issued at
86+
# initialize. Forward the id so notifications/initialized and
87+
# tools/call stay on the same session.
88+
request.add_header(MCP_SESSION_HEADER, session_id)
3889
with OPENER.open(request, timeout=timeout) as response:
3990
body = response.read().decode("utf-8", errors="replace")
91+
response_session_id = response.headers.get(MCP_SESSION_HEADER) or session_id
4092
try:
41-
return json.loads(body)
93+
return json.loads(body), response_session_id
4294
except ValueError:
4395
pass
4496
candidates = []
@@ -51,29 +103,57 @@ def post(url, payload, timeout):
51103
except ValueError:
52104
continue
53105
responses = [c for c in candidates if isinstance(c, dict) and "result" in c]
54-
return responses[-1] if responses else None
106+
return (responses[-1] if responses else None), response_session_id
107+
55108

109+
def rpc(method, params, rpc_id, deadline, session_id=None, url=None):
110+
"""Issue one JSON-RPC request within the shared time budget.
56111
57-
def rpc(method, params, rpc_id, deadline):
58-
"""Issue one JSON-RPC request within the shared time budget."""
112+
``session_id`` is threaded into the Mcp-Session-Id header on every
113+
request after initialize; stateful transports require it. When the
114+
server issues a fresh ``Mcp-Session-Id`` in the response (initialize
115+
is the canonical case), the returned id is propagated so the caller
116+
threads it into every subsequent request on the same session.
117+
"""
118+
if url is None:
119+
url = MCP_URL
59120
remaining = deadline - time.monotonic()
60121
if remaining <= 0.05:
61122
raise TimeoutError("time budget exhausted")
62-
response = post(MCP_URL, {"jsonrpc": "2.0", "id": rpc_id, "method": method, "params": params}, remaining)
123+
response, response_session_id = post(
124+
url,
125+
{"jsonrpc": "2.0", "id": rpc_id, "method": method, "params": params},
126+
remaining,
127+
session_id=session_id,
128+
)
129+
# ``post`` echoes the request id when the server did not issue a new
130+
# one; otherwise the response carries the freshly-issued id. Forward
131+
# whichever the server gave us so stateful transports keep their
132+
# session open across the initialize -> initialized -> tools/call
133+
# handshake.
134+
next_session_id = response_session_id or session_id
63135
if isinstance(response, dict) and "result" in response:
64-
return response["result"]
65-
return None
136+
return response["result"], next_session_id
137+
return None, next_session_id
66138

67139

68-
def notify_initialized(deadline):
140+
def notify_initialized(deadline, session_id=None, url=None):
69141
"""Best-effort notifications/initialized; stateless servers reply 202/empty."""
142+
if url is None:
143+
url = MCP_URL
70144
remaining = deadline - time.monotonic()
71145
if remaining <= 0.05:
72-
return
146+
return session_id
73147
try:
74-
post(MCP_URL, {"jsonrpc": "2.0", "method": "notifications/initialized"}, remaining)
148+
_, response_session_id = post(
149+
url,
150+
{"jsonrpc": "2.0", "method": "notifications/initialized"},
151+
remaining,
152+
session_id=session_id,
153+
)
154+
return response_session_id
75155
except Exception:
76-
pass
156+
return session_id
77157

78158

79159
def extract_context(result):
@@ -93,9 +173,17 @@ def extract_context(result):
93173
return ""
94174

95175

96-
def session_context(repo, workspace, deadline):
97-
"""initialize -> initialized -> tools/call engraphis_session(action=start)."""
98-
rpc(
176+
def session_context(repo, workspace, deadline, mcp_url=None):
177+
"""initialize -> initialized -> tools/call engraphis_session(action=start).
178+
179+
The Mcp-Session-Id returned by initialize is threaded into every
180+
subsequent request so a stateful transport (e.g. the dashboard /mcp
181+
endpoint) keeps the connection open and recognises the tool call as
182+
part of the same session.
183+
"""
184+
if mcp_url is None:
185+
mcp_url = MCP_URL
186+
_, session_id = rpc(
99187
"initialize",
100188
{
101189
"protocolVersion": "2025-03-26",
@@ -104,9 +192,12 @@ def session_context(repo, workspace, deadline):
104192
},
105193
1,
106194
deadline,
195+
url=mcp_url,
196+
)
197+
session_id = (
198+
notify_initialized(deadline, session_id=session_id, url=mcp_url) or session_id
107199
)
108-
notify_initialized(deadline)
109-
result = rpc(
200+
result, _ = rpc(
110201
"tools/call",
111202
{
112203
"name": "engraphis_session",
@@ -123,6 +214,8 @@ def session_context(repo, workspace, deadline):
123214
},
124215
2,
125216
deadline,
217+
session_id=session_id,
218+
url=mcp_url,
126219
)
127220
return extract_context(result)
128221

@@ -139,20 +232,25 @@ def resolve_workspace(cwd, env):
139232
return os.path.basename(os.path.normpath(str(cwd)))
140233

141234

142-
def build_additional_context(context, workspace):
235+
def build_additional_context(context, workspace, max_context_chars=None):
236+
if max_context_chars is None:
237+
max_context_chars = MAX_CONTEXT_CHARS
143238
header = CONTEXT_HEADER.format(workspace=workspace)
144239
footer = CONTEXT_FOOTER
145-
body_budget = MAX_CONTEXT_CHARS - len(header) - len(footer)
240+
body_budget = max_context_chars - len(header) - len(footer)
146241
if body_budget <= 0:
147242
# Header+footer already exceed the budget. Truncate the header so the
148-
# final payload stays within MAX_CONTEXT_CHARS and the agent still gets
243+
# final payload stays within the limit and the agent still gets
149244
# a recognisable prompt header for the workspace.
150-
return (header + footer)[:MAX_CONTEXT_CHARS]
151-
return (header + context[:body_budget] + footer)[:MAX_CONTEXT_CHARS]
245+
return (header + footer)[:max_context_chars]
246+
return (header + context[:body_budget] + footer)[:max_context_chars]
152247

153248

154249
def main():
155-
deadline = time.monotonic() + BUDGET_SECONDS
250+
mcp_url = os.environ.get("ENGRAPHIS_MCP_URL") or MCP_URL
251+
budget_seconds = _env_float("ENGRAPHIS_HOOK_BUDGET_S", BUDGET_SECONDS)
252+
max_context_chars = _env_int("ENGRAPHIS_HOOK_MAX_CHARS", MAX_CONTEXT_CHARS)
253+
deadline = time.monotonic() + budget_seconds
156254
try:
157255
payload = json.loads(sys.stdin.read() or "{}")
158256
except Exception:
@@ -166,7 +264,7 @@ def main():
166264
repo = os.path.basename(os.path.normpath(str(cwd)))
167265
workspace = resolve_workspace(cwd, os.environ)
168266
try:
169-
context = session_context(repo, workspace, deadline)
267+
context = session_context(repo, workspace, deadline, mcp_url=mcp_url)
170268
except Exception:
171269
return 0
172270
if not context:
@@ -175,7 +273,9 @@ def main():
175273
"suppressOutput": False,
176274
"hookSpecificOutput": {
177275
"hookEventName": "SessionStart",
178-
"additionalContext": build_additional_context(context, workspace),
276+
"additionalContext": build_additional_context(
277+
context, workspace, max_context_chars
278+
),
179279
},
180280
}
181281
sys.stdout.write(json.dumps(output))

scripts/install_cc_hook.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,15 +94,19 @@ def _strip_our_entries(wrapper: dict) -> dict | None:
9494
"""Return a new wrapper with our inner entries removed.
9595
9696
Returns ``None`` if the wrapper becomes empty after stripping (caller drops
97-
it). Preserves every sibling inner entry the operator added manually.
97+
it). Preserves every sibling inner entry the operator added manually and
98+
every wrapper-level key (e.g. ``matcher``) so uninstall does not silently
99+
drop the operator's filter config.
98100
"""
99101
remaining = [
100102
entry for entry in wrapper.get("hooks", []) or []
101103
if not _is_our_entry(entry)
102104
]
103105
if not remaining:
104106
return None
105-
return {"hooks": remaining}
107+
new_wrapper = dict(wrapper)
108+
new_wrapper["hooks"] = remaining
109+
return new_wrapper
106110

107111

108112
def _refresh_existing_wrappers(hooks: list) -> bool:
@@ -120,9 +124,10 @@ def _refresh_existing_wrappers(hooks: list) -> bool:
120124
continue
121125
refreshed = True
122126
siblings = [e for e in inner if not _is_our_entry(e)]
123-
# Re-add the fresh entry alongside the siblings so the original
124-
# wrapper is preserved verbatim except for our entry being replaced.
125-
hooks[i] = {"hooks": [*siblings, _hook_entry()]}
127+
# Reuse the existing wrapper dict so any wrapper-level keys the
128+
# operator added (e.g. ``matcher``) are preserved; only swap the
129+
# inner ``hooks`` list.
130+
wrapper["hooks"] = [*siblings, _hook_entry()]
126131
return refreshed
127132

128133

@@ -150,6 +155,10 @@ def uninstall() -> None:
150155
stripped = _strip_our_entries(wrapper)
151156
if stripped is not None:
152157
cleaned.append(stripped)
158+
elif wrapper is settings["hooks"]["SessionStart"][0]:
159+
# No-op, but explicit: a wrapper that becomes empty after
160+
# stripping is dropped (caller removed via ``cleaned.append``).
161+
pass
153162
settings["hooks"]["SessionStart"] = cleaned
154163
if not settings["hooks"]["SessionStart"]:
155164
del settings["hooks"]["SessionStart"]

0 commit comments

Comments
 (0)