evrex-mcp 0.6.0 → 0.8.0
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.
- package/README.md +11 -1
- package/dist/account.js +210 -0
- package/dist/capture.js +2600 -165
- package/dist/continuity.js +343 -0
- package/dist/hook.js +160 -24
- package/dist/import.js +2290 -344
- package/dist/index.js +432 -20
- package/dist/pretool.js +341 -0
- package/dist/tickets.js +29 -1
- package/package.json +22 -17
package/README.md
CHANGED
|
@@ -41,8 +41,18 @@ Reload Cursor afterwards so it picks up the new server.
|
|
|
41
41
|
| Tool | Use it for |
|
|
42
42
|
|---|---|
|
|
43
43
|
| `evrex_why` | Why a specific file is built the way it is, and what was already rejected for it. Call it before a nontrivial edit. |
|
|
44
|
-
| `evrex_search` | Open-ended questions across session and commit history
|
|
44
|
+
| `evrex_search` | Open-ended questions across session and commit history. Returns an index: a gist and a handle per line, dated, source-labelled, and priced by what the conversation behind it cost. |
|
|
45
|
+
| `evrex_expand` | The full record behind index handles you pick — excerpt plus decisions, constraints, rejected approaches. Batch what matters; never expand everything. |
|
|
46
|
+
| `evrex_bottle` | A session's conversation replayed with tool traffic wrung out — for continuing unfinished work, yours or a teammate's. |
|
|
45
47
|
| `evrex_commit_context` | The session behind a commit: what was being attempted and what it decided. Takes a full or abbreviated sha. |
|
|
48
|
+
| `evrex_timeline` | What happened in the repo in the last N days, newest first — sessions and commits interleaved, each line a handle for `evrex_expand`. Search ranks by relevance and cannot answer "what is recent"; this can. |
|
|
49
|
+
|
|
50
|
+
The package also ships four hook binaries, registered by `install.sh`:
|
|
51
|
+
`evrex-capture` (session recording on lifecycle events), `evrex-hook`
|
|
52
|
+
(recovered context on every prompt), `evrex-pretool` (the record for a file at
|
|
53
|
+
the moment an agent edits it), `evrex-account` (reads the model's one-line accounting of whether it used the recovered context, and records it), and `evrex-continuity` (the most recent
|
|
54
|
+
recorded session at startup; after a compaction, the conversation itself,
|
|
55
|
+
re-rendered from the transcript and marked authoritative over the summary).
|
|
46
56
|
|
|
47
57
|
## Configuration
|
|
48
58
|
|
package/dist/account.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/account.ts
|
|
4
|
+
import { statSync, openSync, readSync, closeSync } from "node:fs";
|
|
5
|
+
|
|
6
|
+
// src/client.ts
|
|
7
|
+
var DEFAULT_API_BASE_URL = "https://api.evrex.ai";
|
|
8
|
+
var API_BASE_URL = (process.env.EVREX_API_BASE_URL ?? process.env.EVREX_API_URL ?? DEFAULT_API_BASE_URL).replace(/\/+$/, "");
|
|
9
|
+
var EVREX_TOKEN = process.env.EVREX_TOKEN ?? process.env.EVREX_API_TOKEN ?? null;
|
|
10
|
+
function headers() {
|
|
11
|
+
const base = { "Content-Type": "application/json" };
|
|
12
|
+
if (EVREX_TOKEN) base.Authorization = `Bearer ${EVREX_TOKEN}`;
|
|
13
|
+
return base;
|
|
14
|
+
}
|
|
15
|
+
function describeFailure(method, path, status, statusText) {
|
|
16
|
+
if (status === 401 || status === 403) {
|
|
17
|
+
return EVREX_TOKEN ? `${method} ${path} -> ${status}: the EVREX_TOKEN this server was started with was rejected. It may be revoked or expired.` : `${method} ${path} -> ${status}: this evrex backend requires a credential, but no EVREX_TOKEN is set for this MCP server.`;
|
|
18
|
+
}
|
|
19
|
+
return `${method} ${path} -> ${status} ${statusText}`;
|
|
20
|
+
}
|
|
21
|
+
async function request(method, path, { body, absentIsAnswer } = {}) {
|
|
22
|
+
const res = await fetch(`${API_BASE_URL}${path}`, {
|
|
23
|
+
method,
|
|
24
|
+
headers: headers(),
|
|
25
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
26
|
+
});
|
|
27
|
+
if (res.status === 404 && absentIsAnswer) return null;
|
|
28
|
+
if (!res.ok) throw new Error(describeFailure(method, path, res.status, res.statusText));
|
|
29
|
+
return await res.json();
|
|
30
|
+
}
|
|
31
|
+
var get = (path) => request("GET", path);
|
|
32
|
+
var getOrNull = (path) => request("GET", path, { absentIsAnswer: true });
|
|
33
|
+
var post = (path, body) => request("POST", path, { body });
|
|
34
|
+
var evrexApi = {
|
|
35
|
+
baseUrl: API_BASE_URL,
|
|
36
|
+
repos: () => get("/repos"),
|
|
37
|
+
commits: (repoPath) => get(`/commits?repoPath=${encodeURIComponent(repoPath)}`),
|
|
38
|
+
// Abbreviated shas resolve server-side, so a value pasted from `git log`
|
|
39
|
+
// works here (apps/backend/src/reads/reads.service.ts#resolveSha).
|
|
40
|
+
commit: (sha) => getOrNull(`/commits/${encodeURIComponent(sha)}`),
|
|
41
|
+
sessions: (repoPath) => get(`/sessions?repoPath=${encodeURIComponent(repoPath)}`),
|
|
42
|
+
session: (id) => getOrNull(`/sessions/${encodeURIComponent(id)}`),
|
|
43
|
+
// The paginated transcript — see reads.service.ts#getSessionTurns. `ts, id`
|
|
44
|
+
// ordering server-side makes offsets stable across requests.
|
|
45
|
+
sessionTurns: (id, offset, limit) => getOrNull(
|
|
46
|
+
`/sessions/${encodeURIComponent(id)}/turns?offset=${offset}&limit=${limit}`
|
|
47
|
+
),
|
|
48
|
+
ask: (repoPath, text, filePaths) => post("/ask", { repoPath, text, filePaths }),
|
|
49
|
+
// Evidence-only retrieval (BM25 + embedding), no LLM synthesis call — see
|
|
50
|
+
// apps/backend/src/query/query.service.ts#search. Used by evrex_search,
|
|
51
|
+
// which wants ranked hits fast, not a synthesized paragraph.
|
|
52
|
+
search: (repoPath, text, filePaths) => post("/search", { repoPath, text, filePaths }),
|
|
53
|
+
// Everything that happened in a repo, newest first, bounded by days — the
|
|
54
|
+
// same query the desktop Timeline screen makes. Sessions and commits
|
|
55
|
+
// interleaved, each with the handle evrex_expand takes.
|
|
56
|
+
feedback: (body) => post("/feedback", body),
|
|
57
|
+
timeline: (repoPath, days) => get(
|
|
58
|
+
`/timeline?repoPath=${encodeURIComponent(repoPath)}&days=${encodeURIComponent(String(days))}`
|
|
59
|
+
)
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// src/hook-runtime.ts
|
|
63
|
+
function readStdin() {
|
|
64
|
+
return new Promise((resolve) => {
|
|
65
|
+
let raw = "";
|
|
66
|
+
process.stdin.setEncoding("utf8");
|
|
67
|
+
process.stdin.on("data", (chunk) => raw += chunk);
|
|
68
|
+
process.stdin.on("end", () => resolve(raw));
|
|
69
|
+
process.stdin.on("error", () => resolve(""));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
function withDeadline(work, ms) {
|
|
73
|
+
return Promise.race([
|
|
74
|
+
work.catch(() => null),
|
|
75
|
+
new Promise((resolve) => setTimeout(() => resolve(null), ms))
|
|
76
|
+
]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// src/hook-state.ts
|
|
80
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
81
|
+
import { homedir } from "node:os";
|
|
82
|
+
import { dirname, join } from "node:path";
|
|
83
|
+
function statePath(home = homedir()) {
|
|
84
|
+
return join(home, ".evrex", "hook-state.json");
|
|
85
|
+
}
|
|
86
|
+
function readState(path) {
|
|
87
|
+
try {
|
|
88
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
89
|
+
if (!parsed || typeof parsed !== "object" || typeof parsed.sessions !== "object") {
|
|
90
|
+
return { sessions: {} };
|
|
91
|
+
}
|
|
92
|
+
const sessions = {};
|
|
93
|
+
for (const [id, s] of Object.entries(parsed.sessions ?? {})) {
|
|
94
|
+
if (!s || typeof s !== "object") continue;
|
|
95
|
+
sessions[id] = {
|
|
96
|
+
at: typeof s.at === "string" ? s.at : (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
97
|
+
files: Array.isArray(s.files) ? s.files.filter((f) => typeof f === "string") : [],
|
|
98
|
+
shown: Array.isArray(s.shown) ? s.shown.filter((f) => typeof f === "string") : []
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return { sessions };
|
|
102
|
+
} catch {
|
|
103
|
+
return { sessions: {} };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function shownIn(state, sessionId) {
|
|
107
|
+
return new Set(state.sessions[sessionId]?.shown ?? []);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// src/account.ts
|
|
111
|
+
var DEADLINE_MS = 3e3;
|
|
112
|
+
var TAIL_BYTES = 256 * 1024;
|
|
113
|
+
var HANDLE = /\b(session:[0-9a-f-]{8,}|commit:[0-9a-f]{7,40})\b/gi;
|
|
114
|
+
function parseAccounting(text) {
|
|
115
|
+
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
116
|
+
for (const line of lines) {
|
|
117
|
+
if (!/evrex|recovered[- ]context/i.test(line)) continue;
|
|
118
|
+
const lower = line.toLowerCase();
|
|
119
|
+
const negative = /\b(did not use|didn't use|not used|skipp?ed|ignored|nothing (relevant|useful)|no use)\b/.test(lower);
|
|
120
|
+
const positive = /\b(used|applied|relied on|drew on|built on)\b/.test(lower);
|
|
121
|
+
const handles = [...line.matchAll(HANDLE)].map((m) => m[1].toLowerCase());
|
|
122
|
+
if (negative && !positive) {
|
|
123
|
+
return { used: [], skipped: line.replace(/^[-*•\s]+/, "").slice(0, 300) };
|
|
124
|
+
}
|
|
125
|
+
if (positive) return { used: [...new Set(handles)], skipped: null };
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
function lastAssistantText(transcriptPath) {
|
|
130
|
+
let size;
|
|
131
|
+
try {
|
|
132
|
+
size = statSync(transcriptPath).size;
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
const start = Math.max(0, size - TAIL_BYTES);
|
|
137
|
+
const fd = openSync(transcriptPath, "r");
|
|
138
|
+
const buf = Buffer.alloc(size - start);
|
|
139
|
+
try {
|
|
140
|
+
readSync(fd, buf, 0, buf.length, start);
|
|
141
|
+
} finally {
|
|
142
|
+
closeSync(fd);
|
|
143
|
+
}
|
|
144
|
+
const lines = buf.toString("utf-8").split("\n");
|
|
145
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
146
|
+
let rec;
|
|
147
|
+
try {
|
|
148
|
+
rec = JSON.parse(lines[i]);
|
|
149
|
+
} catch {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (rec.type !== "assistant") continue;
|
|
153
|
+
const content = rec.message?.content;
|
|
154
|
+
if (typeof content === "string") return content;
|
|
155
|
+
if (Array.isArray(content)) {
|
|
156
|
+
const text = content.filter((b) => !!b && b.type === "text").map((b) => b.text).join("\n");
|
|
157
|
+
if (text.trim()) return text;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
function feedbackFor(accounting, shown) {
|
|
163
|
+
const full = (handle) => {
|
|
164
|
+
if (shown.has(handle)) return handle;
|
|
165
|
+
if (handle.startsWith("commit:")) {
|
|
166
|
+
for (const s of shown) if (s.startsWith(handle)) return s;
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
};
|
|
170
|
+
if (accounting.skipped) {
|
|
171
|
+
return [...shown].slice(-8).map((target) => ({ target, signal: "not_helpful", note: accounting.skipped }));
|
|
172
|
+
}
|
|
173
|
+
const out = [];
|
|
174
|
+
for (const h of accounting.used) {
|
|
175
|
+
const target = full(h);
|
|
176
|
+
if (target) out.push({ target, signal: "helpful" });
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
async function main() {
|
|
181
|
+
const raw = await withDeadline(readStdin(), DEADLINE_MS);
|
|
182
|
+
if (!raw) return;
|
|
183
|
+
let input;
|
|
184
|
+
try {
|
|
185
|
+
input = JSON.parse(raw);
|
|
186
|
+
} catch {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const sessionId = input.session_id ?? "";
|
|
190
|
+
if (!sessionId) return;
|
|
191
|
+
const shown = shownIn(readState(statePath()), sessionId);
|
|
192
|
+
if (shown.size === 0) return;
|
|
193
|
+
const text = input.last_assistant_message ?? (input.transcript_path ? lastAssistantText(input.transcript_path) : null);
|
|
194
|
+
if (!text) return;
|
|
195
|
+
const accounting = parseAccounting(text);
|
|
196
|
+
if (!accounting) return;
|
|
197
|
+
const started = Date.now();
|
|
198
|
+
for (const body of feedbackFor(accounting, shown)) {
|
|
199
|
+
if (Date.now() - started > DEADLINE_MS) break;
|
|
200
|
+
await withDeadline(evrexApi.feedback(body).catch(() => null), DEADLINE_MS);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (process.argv[1] && /account\.(js|ts)$/.test(process.argv[1])) {
|
|
204
|
+
main().catch(() => void 0);
|
|
205
|
+
}
|
|
206
|
+
export {
|
|
207
|
+
feedbackFor,
|
|
208
|
+
lastAssistantText,
|
|
209
|
+
parseAccounting
|
|
210
|
+
};
|