evrex-mcp 0.7.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 +2592 -167
- package/dist/continuity.js +83 -10
- package/dist/hook.js +106 -11
- package/dist/import.js +2286 -350
- package/dist/index.js +223 -13
- package/dist/pretool.js +67 -16
- package/dist/tickets.js +24 -1
- package/package.json +18 -17
package/dist/index.js
CHANGED
|
@@ -63,7 +63,14 @@ var init_client = __esm({
|
|
|
63
63
|
// Evidence-only retrieval (BM25 + embedding), no LLM synthesis call — see
|
|
64
64
|
// apps/backend/src/query/query.service.ts#search. Used by evrex_search,
|
|
65
65
|
// which wants ranked hits fast, not a synthesized paragraph.
|
|
66
|
-
search: (repoPath, text, filePaths) => post("/search", { repoPath, text, filePaths })
|
|
66
|
+
search: (repoPath, text, filePaths) => post("/search", { repoPath, text, filePaths }),
|
|
67
|
+
// Everything that happened in a repo, newest first, bounded by days — the
|
|
68
|
+
// same query the desktop Timeline screen makes. Sessions and commits
|
|
69
|
+
// interleaved, each with the handle evrex_expand takes.
|
|
70
|
+
feedback: (body) => post("/feedback", body),
|
|
71
|
+
timeline: (repoPath, days) => get(
|
|
72
|
+
`/timeline?repoPath=${encodeURIComponent(repoPath)}&days=${encodeURIComponent(String(days))}`
|
|
73
|
+
)
|
|
67
74
|
};
|
|
68
75
|
}
|
|
69
76
|
});
|
|
@@ -434,11 +441,13 @@ var MAX_ITEMS_PER_CATEGORY = 6;
|
|
|
434
441
|
var EXTRACTION_SYSTEM_PROMPT = [
|
|
435
442
|
"You extract structured reasoning from a real coding-agent session transcript for Evrex, a tool that recovers WHY code changed, not just what changed.",
|
|
436
443
|
`Each line is labeled "user" (the human engineer actually typed this), "assistant" (the agent), or "tool_result" (raw output from a tool call, e.g. command stdout \u2014 NOT something either party said; never attribute a "tool_result" line's content to "engineer" as raisedBy/source).`,
|
|
444
|
+
'Only tool calls that FAILED are included, truncated. A "tool_result" line is evidence that the approach the assistant was taking at that point was tried and did not work: read it together with the assistant lines around it, and where the failure led to a change of approach, record the abandoned one as a rejected approach with the failure as its reason. A trivial failure \u2014 a typo in a path, a command re-run successfully a line later \u2014 is not a rejected approach.',
|
|
437
445
|
"Only extract items explicitly present in the transcript below. Never invent, infer beyond the text, or pad categories with generic filler.",
|
|
438
446
|
"If a category has nothing genuinely present, return an empty array for it \u2014 an empty result is correct and expected, not a failure.",
|
|
439
447
|
`Cap each array at ${MAX_ITEMS_PER_CATEGORY} items \u2014 pick the most consequential ones.`,
|
|
440
448
|
'"atMessage" is the [N] index of the transcript line the item came from.',
|
|
441
|
-
'For a rejected approach, "reason" is why it was turned down and "tradeoff" is what was given up by not taking it. Fill "tradeoff" from the transcript whenever the cost is stated or clearly implied; leave it empty only when the transcript genuinely says nothing about it.'
|
|
449
|
+
'For a rejected approach, "reason" is why it was turned down and "tradeoff" is what was given up by not taking it. Fill "tradeoff" from the transcript whenever the cost is stated or clearly implied; leave it empty only when the transcript genuinely says nothing about it.',
|
|
450
|
+
'A "procedure" is a reusable, multi-step way of doing something in this repository that the transcript shows actually working \u2014 adding and registering a migration, cutting a release, installing a hook, running a particular check \u2014 written as the concrete steps somebody would follow next time, each step one line with the real command or file where the transcript has it. Only when the steps are visible in the transcript and were carried out, never a plan that was proposed and not done; a one-off fix is not a procedure.'
|
|
442
451
|
].join(" ");
|
|
443
452
|
|
|
444
453
|
// ../../packages/llm-core/src/synthesis.ts
|
|
@@ -1166,28 +1175,60 @@ async function evrexWhy(filePath, question) {
|
|
|
1166
1175
|
const byRelevance = (items) => [...items].sort(
|
|
1167
1176
|
(a, b) => Number(aboutTarget(b)) - Number(aboutTarget(a))
|
|
1168
1177
|
);
|
|
1178
|
+
const commitShas = [...new Set(evidence.filter((e) => e.kind === "commit").map((e) => e.refId))].slice(0, MAX_ITEMS);
|
|
1179
|
+
const commits = (await Promise.all(commitShas.map((sha) => evrexApi.commit(sha).catch(() => null)))).filter(
|
|
1180
|
+
(c) => c !== null && (c.statedInsights?.length ?? 0) > 0
|
|
1181
|
+
);
|
|
1182
|
+
const statedOf = (kind) => commits.flatMap(
|
|
1183
|
+
(c) => (c.statedInsights ?? []).filter((i) => i.kind === kind).map((i) => ({ text: i.text, stated: `commit:${c.sha.slice(0, 12)}` }))
|
|
1184
|
+
);
|
|
1169
1185
|
const rejected = byRelevance(sessions.flatMap((s) => s.rejected)).slice(0, MAX_ITEMS);
|
|
1170
1186
|
const constraints = byRelevance(sessions.flatMap((s) => s.constraints)).slice(0, MAX_ITEMS);
|
|
1171
1187
|
const decisions = byRelevance(sessions.flatMap((s) => s.decisions)).slice(0, MAX_ITEMS);
|
|
1188
|
+
const procedures = byRelevance(sessions.flatMap((s) => s.procedures ?? [])).slice(0, MAX_ITEMS);
|
|
1189
|
+
const statedRejected = statedOf("rejected").slice(0, MAX_ITEMS);
|
|
1190
|
+
const statedConstraints = statedOf("constraint").slice(0, MAX_ITEMS);
|
|
1191
|
+
const statedDecisions = statedOf("decision").slice(0, MAX_ITEMS);
|
|
1192
|
+
const statedLine = (i) => `- ${i.text} (stated in ${i.stated})`;
|
|
1172
1193
|
const parts = [];
|
|
1194
|
+
const marked = sessions.filter((s) => (s.feedback ?? []).some((f) => f.itemId === null && f.signal !== "helpful"));
|
|
1195
|
+
if (marked.length > 0) {
|
|
1196
|
+
parts.push(
|
|
1197
|
+
marked.map((s) => `session:${s.id} \u2014 ${feedbackLines((s.feedback ?? []).filter((f) => f.itemId === null))}`).join("\n")
|
|
1198
|
+
);
|
|
1199
|
+
}
|
|
1173
1200
|
const heuristic = sessions.some((s) => s.insightsSource === "heuristic");
|
|
1174
|
-
if (rejected.length > 0) {
|
|
1201
|
+
if (rejected.length + statedRejected.length > 0) {
|
|
1175
1202
|
parts.push(
|
|
1176
|
-
"REJECTED APPROACHES (do not re-propose these without new information):\n" +
|
|
1203
|
+
"REJECTED APPROACHES (do not re-propose these without new information):\n" + [
|
|
1204
|
+
...rejected.map((r) => `- ${r.title} \u2014 ${r.reason}${r.tradeoff ? ` (tradeoff: ${r.tradeoff})` : ""}${itemMark(sessions.flatMap((s) => s.feedback ?? []), r.id)}`),
|
|
1205
|
+
...statedRejected.map(statedLine)
|
|
1206
|
+
].join("\n")
|
|
1177
1207
|
);
|
|
1178
1208
|
}
|
|
1179
|
-
if (constraints.length > 0) {
|
|
1180
|
-
parts.push(
|
|
1209
|
+
if (constraints.length + statedConstraints.length > 0) {
|
|
1210
|
+
parts.push(
|
|
1211
|
+
"CONSTRAINTS:\n" + [...constraints.map((c) => `- [${c.source}] ${c.text}`), ...statedConstraints.map(statedLine)].join("\n")
|
|
1212
|
+
);
|
|
1213
|
+
}
|
|
1214
|
+
if (decisions.length + statedDecisions.length > 0) {
|
|
1215
|
+
parts.push(
|
|
1216
|
+
"PRIOR DECISIONS:\n" + [...decisions.map((d) => `- ${d.title}: ${d.detail}`), ...statedDecisions.map(statedLine)].join("\n")
|
|
1217
|
+
);
|
|
1181
1218
|
}
|
|
1182
|
-
|
|
1183
|
-
|
|
1219
|
+
const proceduresText = proceduresBlock(procedures);
|
|
1220
|
+
if (proceduresText) parts.push(proceduresText);
|
|
1221
|
+
if (commits.length > 0) {
|
|
1222
|
+
parts.push(
|
|
1223
|
+
`Items marked "stated in commit:\u2026" were written as Evrex-Rejected / Evrex-Constraint / Evrex-Decision trailers by whoever committed \u2014 the committer's own account at the moment, not extracted by a model.`
|
|
1224
|
+
);
|
|
1184
1225
|
}
|
|
1185
1226
|
if (heuristic && (rejected.length > 0 || constraints.length > 0 || decisions.length > 0)) {
|
|
1186
1227
|
parts.push(
|
|
1187
1228
|
"NOTE: the blocks above were extracted by cue-phrase matching, not by a model reading the conversation \u2014 they may be incomplete or miss context."
|
|
1188
1229
|
);
|
|
1189
1230
|
}
|
|
1190
|
-
const hasBlocks = rejected.length > 0 || constraints.length > 0 || decisions.length > 0;
|
|
1231
|
+
const hasBlocks = rejected.length + statedRejected.length > 0 || constraints.length + statedConstraints.length > 0 || decisions.length + statedDecisions.length > 0;
|
|
1191
1232
|
if (synthesisEnabled()) {
|
|
1192
1233
|
const answer = await synthesize(text, evidence);
|
|
1193
1234
|
parts.push(
|
|
@@ -1229,7 +1270,7 @@ async function evrexSearch(query) {
|
|
|
1229
1270
|
const lines = results.slice(0, MAX_ITEMS * 2).map(({ repo, e }) => {
|
|
1230
1271
|
const conf = confidenceLabel(e.provenance);
|
|
1231
1272
|
const repoTag = multiRepo ? ` \xB7 ${repo.name}` : "";
|
|
1232
|
-
const handle =
|
|
1273
|
+
const handle = e.kind === "commit" ? `commit:${e.refId.slice(0, 12)}` : `${e.kind}:${e.refId}`;
|
|
1233
1274
|
return `- ${handle} [${evidenceLabel(e)} ${conf.trim()}${dateLabel(e.at)}${spentLabel(e.spentTokens)}${repoTag}] ${truncate(e.excerpt.replace(/\s+/g, " ").trim(), INDEX_EXCERPT)}`;
|
|
1234
1275
|
});
|
|
1235
1276
|
return [
|
|
@@ -1238,6 +1279,63 @@ async function evrexSearch(query) {
|
|
|
1238
1279
|
"Where a line says `N spent`, that is what the conversation behind it cost in tokens \u2014 the records most expensive to rediscover are usually the ones worth expanding first. This is the index, not the record \u2014 each line is a gist, roughly a quarter of what the underlying item says. If one of them looks like the answer, call evrex_expand with its handle (several at once) to read it in full along with any decisions, constraints and rejected approaches attached to it. Expanding everything costs more than the old single-shot search did; expanding the two that matter costs much less. If none of them look relevant, say the record does not cover it rather than expanding on spec."
|
|
1239
1280
|
].join("\n");
|
|
1240
1281
|
}
|
|
1282
|
+
var TIMELINE_DEFAULT_DAYS = 7;
|
|
1283
|
+
var TIMELINE_MAX_DAYS = 90;
|
|
1284
|
+
var TIMELINE_DEFAULT_LIMIT = 30;
|
|
1285
|
+
var TIMELINE_MAX_LIMIT = 200;
|
|
1286
|
+
async function evrexTimeline(days = TIMELINE_DEFAULT_DAYS, limit = TIMELINE_DEFAULT_LIMIT) {
|
|
1287
|
+
const window = Math.min(TIMELINE_MAX_DAYS, Math.max(1, Math.floor(days) || TIMELINE_DEFAULT_DAYS));
|
|
1288
|
+
const cap2 = Math.min(TIMELINE_MAX_LIMIT, Math.max(1, Math.floor(limit) || TIMELINE_DEFAULT_LIMIT));
|
|
1289
|
+
const repos = await resolveRepos();
|
|
1290
|
+
if (repos.length === 0) return "No indexed repos found (GET /repos returned none).";
|
|
1291
|
+
const multiRepo = repos.length > 1;
|
|
1292
|
+
const perRepo = await Promise.all(
|
|
1293
|
+
repos.map(async (repo) => {
|
|
1294
|
+
try {
|
|
1295
|
+
const entries = await evrexApi.timeline(repo.id, window);
|
|
1296
|
+
return entries.map((entry) => ({ repo, entry }));
|
|
1297
|
+
} catch {
|
|
1298
|
+
return [];
|
|
1299
|
+
}
|
|
1300
|
+
})
|
|
1301
|
+
);
|
|
1302
|
+
const all = perRepo.flat().sort((a, b) => Date.parse(b.entry.at) - Date.parse(a.entry.at));
|
|
1303
|
+
const where = multiRepo ? "the indexed repos" : repos[0].name;
|
|
1304
|
+
if (all.length === 0) {
|
|
1305
|
+
return `Nothing recorded in ${where} in the last ${window} day${window === 1 ? "" : "s"}. No captured session and no ingested commit fall in that window; work done without capture would not appear here. Widen the window with a larger \`days\`.`;
|
|
1306
|
+
}
|
|
1307
|
+
const shown = all.slice(0, cap2);
|
|
1308
|
+
const lines = shown.map(({ repo, entry }) => formatTimelineLine(entry, multiRepo ? repo.name : null));
|
|
1309
|
+
const omitted = all.length - shown.length;
|
|
1310
|
+
return [
|
|
1311
|
+
`Last ${window} day${window === 1 ? "" : "s"} in ${where}, newest first \u2014 ${all.length} record${all.length === 1 ? "" : "s"}${omitted > 0 ? `, ${shown.length} shown` : ""}:`,
|
|
1312
|
+
...lines,
|
|
1313
|
+
"",
|
|
1314
|
+
(omitted > 0 ? `${omitted} older record${omitted === 1 ? "" : "s"} in the window not shown; raise \`limit\` to see them. ` : "") + "This is the index, not the record. A commit line nests under its session (`\u21B3 session:\u2026`) when the link is verified or matched. For what a session decided, call evrex_expand with its handle; to continue its work, evrex_bottle; for a commit's story, evrex_commit_context. Expand only what the task needs."
|
|
1315
|
+
].join("\n");
|
|
1316
|
+
}
|
|
1317
|
+
function formatTimelineLine(entry, repoName) {
|
|
1318
|
+
const handle = entry.kind === "commit" ? `commit:${entry.refId.slice(0, 12)}` : `session:${entry.refId}`;
|
|
1319
|
+
const tags = [entry.kind === "session" ? entry.sourceKind ?? "session" : "commit"];
|
|
1320
|
+
if (entry.kind === "commit") {
|
|
1321
|
+
if (entry.linkStatus === "inferred" && entry.confidence !== void 0) {
|
|
1322
|
+
tags.push(`${Math.round(entry.confidence * 100)}%`);
|
|
1323
|
+
} else if (entry.linkStatus) {
|
|
1324
|
+
tags.push(entry.linkStatus);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
if (entry.author && entry.author !== "Unknown") tags.push(entry.author);
|
|
1328
|
+
if (entry.meta) tags.push(entry.meta);
|
|
1329
|
+
if (repoName) tags.push(repoName);
|
|
1330
|
+
const nest = entry.kind === "commit" && entry.sessionId && entry.linkStatus !== "inferred" ? ` \u21B3 session:${entry.sessionId}` : "";
|
|
1331
|
+
const title = truncate(entry.title.replace(/\s+/g, " ").trim(), INDEX_EXCERPT);
|
|
1332
|
+
return `- ${dateLabel(entry.at).trim() || "undated"} ${handle} [${tags.join(" \xB7 ")}${nest}] ${title}`;
|
|
1333
|
+
}
|
|
1334
|
+
function proceduresBlock(procedures) {
|
|
1335
|
+
if (procedures.length === 0) return "";
|
|
1336
|
+
return "PROCEDURES (how this was done here, as steps that worked once \u2014 check they still apply before following them):\n" + procedures.slice(0, MAX_ITEMS).map((p) => `- ${p.title}
|
|
1337
|
+
${p.steps.slice(0, 12).map((step, i) => ` ${i + 1}. ${step}`).join("\n")}`).join("\n");
|
|
1338
|
+
}
|
|
1241
1339
|
async function evrexExpand(handles) {
|
|
1242
1340
|
if (handles.length === 0) return "No handles given. Pass ids from evrex_search, e.g. commit:069a0b5.";
|
|
1243
1341
|
const parts = [];
|
|
@@ -1263,9 +1361,20 @@ Open in Evrex: evrex://open?handle=commit:${id}`);
|
|
|
1263
1361
|
`${handle.toUpperCase()}: ${session.intent}`,
|
|
1264
1362
|
`Open in Evrex: evrex://open?handle=session:${id}`
|
|
1265
1363
|
];
|
|
1364
|
+
if (session.parentSessionId) {
|
|
1365
|
+
const what = session.subagent?.agentType ? ` (${session.subagent.agentType})` : "";
|
|
1366
|
+
block.push(`SUB-AGENT${what} of session:${session.parentSessionId} \u2014 expand that for what it was asked to do and what became of it.`);
|
|
1367
|
+
}
|
|
1368
|
+
if (session.subagentIds && session.subagentIds.length > 0) {
|
|
1369
|
+
block.push(
|
|
1370
|
+
`RAN ${session.subagentIds.length} SUB-AGENT${session.subagentIds.length === 1 ? "" : "S"}: ` + session.subagentIds.map((s) => `session:${s}`).join(", ") + " \u2014 their own decisions and rejected approaches are in their own records."
|
|
1371
|
+
);
|
|
1372
|
+
}
|
|
1373
|
+
const fb = feedbackLines(session.feedback);
|
|
1374
|
+
if (fb) block.push(fb);
|
|
1266
1375
|
if (session.rejected.length > 0) {
|
|
1267
1376
|
block.push(
|
|
1268
|
-
"REJECTED APPROACHES:\n" + session.rejected.slice(0, MAX_ITEMS).map((r) => `- ${r.title} \u2014 ${r.reason}${r.tradeoff ? ` (tradeoff: ${r.tradeoff})` : ""}`).join("\n")
|
|
1377
|
+
"REJECTED APPROACHES:\n" + session.rejected.slice(0, MAX_ITEMS).map((r) => `- ${r.title} \u2014 ${r.reason}${r.tradeoff ? ` (tradeoff: ${r.tradeoff})` : ""}${itemMark(session.feedback, r.id)}`).join("\n")
|
|
1269
1378
|
);
|
|
1270
1379
|
}
|
|
1271
1380
|
if (session.constraints.length > 0) {
|
|
@@ -1278,6 +1387,8 @@ Open in Evrex: evrex://open?handle=commit:${id}`);
|
|
|
1278
1387
|
"PRIOR DECISIONS:\n" + session.decisions.slice(0, MAX_ITEMS).map((d) => `- ${d.title}: ${d.detail}`).join("\n")
|
|
1279
1388
|
);
|
|
1280
1389
|
}
|
|
1390
|
+
const procedures = proceduresBlock(session.procedures ?? []);
|
|
1391
|
+
if (procedures) block.push(procedures);
|
|
1281
1392
|
parts.push(block.join("\n"));
|
|
1282
1393
|
}
|
|
1283
1394
|
parts.push(
|
|
@@ -1285,6 +1396,46 @@ Open in Evrex: evrex://open?handle=commit:${id}`);
|
|
|
1285
1396
|
);
|
|
1286
1397
|
return parts.join("\n\n");
|
|
1287
1398
|
}
|
|
1399
|
+
var FEEDBACK_SIGNALS = ["helpful", "not_helpful", "stale", "wrong", "superseded"];
|
|
1400
|
+
function feedbackLines(feedback) {
|
|
1401
|
+
if (!feedback || feedback.length === 0) return "";
|
|
1402
|
+
const lines = feedback.slice(0, MAX_ITEMS).map((f) => {
|
|
1403
|
+
const scope = f.itemId ? ` on ${f.itemId}` : "";
|
|
1404
|
+
const by = f.supersededBy ? ` by ${f.supersededBy}` : "";
|
|
1405
|
+
const note = f.note ? `: ${truncate(f.note, 160)}` : "";
|
|
1406
|
+
return `- ${f.signal}${by}${scope}${dateLabel(f.at)}${note}`;
|
|
1407
|
+
});
|
|
1408
|
+
return `READER FEEDBACK (what someone said after reading this \u2014 weigh it above the record's own date):
|
|
1409
|
+
${lines.join("\n")}`;
|
|
1410
|
+
}
|
|
1411
|
+
function itemMark(feedback, itemId) {
|
|
1412
|
+
const marks = (feedback ?? []).filter((f2) => f2.itemId === itemId && f2.signal !== "helpful");
|
|
1413
|
+
if (marks.length === 0) return "";
|
|
1414
|
+
const f = marks[0];
|
|
1415
|
+
return ` [marked ${f.signal}${f.supersededBy ? ` by ${f.supersededBy}` : ""}${dateLabel(f.at)}${f.note ? `: ${truncate(f.note, 100)}` : ""}]`;
|
|
1416
|
+
}
|
|
1417
|
+
async function evrexFeedback(handle, signal, note, itemId, supersededBy) {
|
|
1418
|
+
if (!FEEDBACK_SIGNALS.includes(signal)) {
|
|
1419
|
+
return `"${signal}" is not a signal. One of: ${FEEDBACK_SIGNALS.join(", ")}.`;
|
|
1420
|
+
}
|
|
1421
|
+
if (signal === "superseded" && !supersededBy) {
|
|
1422
|
+
return `"superseded" names the record that replaced this one \u2014 pass superseded_by with its handle.`;
|
|
1423
|
+
}
|
|
1424
|
+
try {
|
|
1425
|
+
const saved = await evrexApi.feedback({
|
|
1426
|
+
target: handle.trim(),
|
|
1427
|
+
signal,
|
|
1428
|
+
...note ? { note } : {},
|
|
1429
|
+
...itemId ? { itemId } : {},
|
|
1430
|
+
...supersededBy ? { supersededBy } : {}
|
|
1431
|
+
});
|
|
1432
|
+
const where = itemId ? ` (item ${itemId})` : "";
|
|
1433
|
+
const effect = signal === "stale" || signal === "wrong" || signal === "superseded" ? itemId ? "The item will carry this label wherever it is shown." : "The record now ranks at half weight in retrieval and carries this label wherever it is shown; it is not hidden." : "Recorded beside the record.";
|
|
1434
|
+
return `Recorded ${saved.signal} on ${handle}${where}${saved.supersededBy ? ` \u2014 superseded by ${saved.supersededBy}` : ""}. ${effect}`;
|
|
1435
|
+
} catch (err) {
|
|
1436
|
+
return `Could not record feedback: ${err instanceof Error ? err.message : String(err)}`;
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1288
1439
|
var BOTTLE_PAGE = 200;
|
|
1289
1440
|
var BOTTLE_TAIL_TURNS = 400;
|
|
1290
1441
|
async function evrexBottle(sessionRef) {
|
|
@@ -1308,6 +1459,23 @@ async function evrexBottle(sessionRef) {
|
|
|
1308
1459
|
`For what it concluded \u2014 decisions, constraints, rejected approaches \u2014 use evrex_expand ["session:${id}"].`
|
|
1309
1460
|
].join("\n\n");
|
|
1310
1461
|
}
|
|
1462
|
+
function statedBlocks(items, handle) {
|
|
1463
|
+
const of = (kind) => items.filter((i) => i.kind === kind).map((i) => `- ${i.text}`);
|
|
1464
|
+
const rejected = of("rejected");
|
|
1465
|
+
const constraints = of("constraint");
|
|
1466
|
+
const decisions = of("decision");
|
|
1467
|
+
if (rejected.length + constraints.length + decisions.length === 0) return "";
|
|
1468
|
+
const parts = [
|
|
1469
|
+
`STATED IN THE COMMIT (${handle} wrote these as trailers at commit time \u2014 the committer's own account, not extracted by a model):`
|
|
1470
|
+
];
|
|
1471
|
+
if (rejected.length) parts.push(`REJECTED:
|
|
1472
|
+
${rejected.join("\n")}`);
|
|
1473
|
+
if (constraints.length) parts.push(`CONSTRAINTS:
|
|
1474
|
+
${constraints.join("\n")}`);
|
|
1475
|
+
if (decisions.length) parts.push(`DECISIONS:
|
|
1476
|
+
${decisions.join("\n")}`);
|
|
1477
|
+
return parts.join("\n");
|
|
1478
|
+
}
|
|
1311
1479
|
async function evrexCommitContext(sha) {
|
|
1312
1480
|
const commit = await evrexApi.commit(sha);
|
|
1313
1481
|
if (!commit) return `No commit found for sha ${sha}.`;
|
|
@@ -1315,6 +1483,8 @@ async function evrexCommitContext(sha) {
|
|
|
1315
1483
|
`COMMIT ${commit.sha.slice(0, 8)}: ${commit.message}${commit.body ? `
|
|
1316
1484
|
${truncate(commit.body, MAX_EXCERPT)}` : ""}`
|
|
1317
1485
|
];
|
|
1486
|
+
const commitFeedback = feedbackLines(commit.feedback);
|
|
1487
|
+
if (commitFeedback) parts.push(commitFeedback);
|
|
1318
1488
|
if (commit.link) {
|
|
1319
1489
|
const session = await evrexApi.session(commit.link.sessionId);
|
|
1320
1490
|
const status = commit.link.provenance.status;
|
|
@@ -1339,9 +1509,16 @@ ${truncate(commit.body, MAX_EXCERPT)}` : ""}`
|
|
|
1339
1509
|
}
|
|
1340
1510
|
parts.push(`OUTCOME: ${truncate(session.outcome, MAX_EXCERPT)}`);
|
|
1341
1511
|
}
|
|
1342
|
-
} else {
|
|
1512
|
+
} else if (!commit.agentTrailers?.length) {
|
|
1343
1513
|
parts.push("No session linked \u2014 no recorded reasoning behind this commit.");
|
|
1344
1514
|
}
|
|
1515
|
+
const stated = statedBlocks(commit.statedInsights ?? [], `commit:${commit.sha.slice(0, 12)}`);
|
|
1516
|
+
if (stated) parts.push(stated);
|
|
1517
|
+
for (const t of commit.agentTrailers ?? []) {
|
|
1518
|
+
parts.push(
|
|
1519
|
+
`SESSION RECORDED ELSEWHERE (${t.label}): ${t.value} \u2014 evrex holds this pointer, not the transcript, so nothing below was extracted from it. Open it for the reasoning behind this commit; do not treat this line as recovered context.`
|
|
1520
|
+
);
|
|
1521
|
+
}
|
|
1345
1522
|
if (commit.relatedCommitIds.length > 0) {
|
|
1346
1523
|
const relatedShas = commit.relatedCommitIds.slice(0, MAX_ITEMS);
|
|
1347
1524
|
const related = (await Promise.all(relatedShas.map((s) => evrexApi.commit(s)))).filter(
|
|
@@ -1355,7 +1532,7 @@ ${lines.join("\n")}`);
|
|
|
1355
1532
|
}
|
|
1356
1533
|
|
|
1357
1534
|
// src/index.ts
|
|
1358
|
-
var VERSION = "0.
|
|
1535
|
+
var VERSION = "0.8.0";
|
|
1359
1536
|
var server = new McpServer({ name: "evrex", version: VERSION });
|
|
1360
1537
|
server.registerTool(
|
|
1361
1538
|
"evrex_why",
|
|
@@ -1400,6 +1577,39 @@ server.registerTool(
|
|
|
1400
1577
|
return { content: [{ type: "text", text }] };
|
|
1401
1578
|
}
|
|
1402
1579
|
);
|
|
1580
|
+
server.registerTool(
|
|
1581
|
+
"evrex_timeline",
|
|
1582
|
+
{
|
|
1583
|
+
title: "What happened in this repo lately",
|
|
1584
|
+
description: "Everything recorded in this repo in the last N days, newest first \u2014 captured agent sessions and ingested commits interleaved, each line a handle evrex_expand or evrex_bottle takes. Use it to pick up a repo after time away ('what happened this week', 'what did the team do while I was out', 'what is in progress'), or before proposing work that may already be underway. It is ordered by time, not relevance: evrex_search cannot answer 'what is recent' because nothing about recency is in a query. Returns an index, not the record \u2014 expand only the handles the task needs.",
|
|
1585
|
+
inputSchema: {
|
|
1586
|
+
days: z.number().int().min(1).max(90).optional().describe("How far back to look, in days. Default 7, maximum 90."),
|
|
1587
|
+
limit: z.number().int().min(1).max(200).optional().describe("At most this many lines, newest first. Default 30, maximum 200.")
|
|
1588
|
+
}
|
|
1589
|
+
},
|
|
1590
|
+
async ({ days, limit }) => {
|
|
1591
|
+
const text = await evrexTimeline(days, limit);
|
|
1592
|
+
return { content: [{ type: "text", text }] };
|
|
1593
|
+
}
|
|
1594
|
+
);
|
|
1595
|
+
server.registerTool(
|
|
1596
|
+
"evrex_feedback",
|
|
1597
|
+
{
|
|
1598
|
+
title: "Say something about a record after reading it",
|
|
1599
|
+
description: "Mark a record \u2014 a session or a commit from evrex_search / evrex_why / evrex_expand \u2014 as helpful, not_helpful, stale, wrong, or superseded by another record. Use it when the record led you astray: a decision a later change reversed, a rejected approach that turned out to be the right one, a constraint that no longer holds \u2014 or when it saved real work. A stale/wrong/superseded record ranks lower afterwards and carries the label on every surface; it is never hidden. Pass item_id (e.g. rej:llm:2) to mark one item rather than the whole record.",
|
|
1600
|
+
inputSchema: {
|
|
1601
|
+
handle: z.string().describe("session:<id> or commit:<sha>, as evrex_search prints it"),
|
|
1602
|
+
signal: z.enum(["helpful", "not_helpful", "stale", "wrong", "superseded"]),
|
|
1603
|
+
note: z.string().optional().describe("One line on why \u2014 what you found instead"),
|
|
1604
|
+
item_id: z.string().optional().describe("One item within the record, e.g. rej:llm:2, con:llm:0, dec:llm:1"),
|
|
1605
|
+
superseded_by: z.string().optional().describe("For superseded: the handle of the record that replaced it")
|
|
1606
|
+
}
|
|
1607
|
+
},
|
|
1608
|
+
async ({ handle, signal, note, item_id, superseded_by }) => {
|
|
1609
|
+
const text = await evrexFeedback(handle, signal, note, item_id, superseded_by);
|
|
1610
|
+
return { content: [{ type: "text", text }] };
|
|
1611
|
+
}
|
|
1612
|
+
);
|
|
1403
1613
|
server.registerTool(
|
|
1404
1614
|
"evrex_bottle",
|
|
1405
1615
|
{
|
package/dist/pretool.js
CHANGED
|
@@ -49,7 +49,14 @@ var evrexApi = {
|
|
|
49
49
|
// Evidence-only retrieval (BM25 + embedding), no LLM synthesis call — see
|
|
50
50
|
// apps/backend/src/query/query.service.ts#search. Used by evrex_search,
|
|
51
51
|
// which wants ranked hits fast, not a synthesized paragraph.
|
|
52
|
-
search: (repoPath, text, filePaths) => post("/search", { repoPath, text, filePaths })
|
|
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
|
+
)
|
|
53
60
|
};
|
|
54
61
|
|
|
55
62
|
// src/hook-runtime.ts
|
|
@@ -91,6 +98,9 @@ function confidenceLabel(p) {
|
|
|
91
98
|
if (p.status === "inferred") return ` ${Math.round((p.confidence ?? 0) * 100)}%`;
|
|
92
99
|
return ` ${p.status}`;
|
|
93
100
|
}
|
|
101
|
+
function handleFor(e) {
|
|
102
|
+
return e.kind === "commit" ? `commit:${e.refId.slice(0, 12)}` : `${e.kind}:${e.refId}`;
|
|
103
|
+
}
|
|
94
104
|
function evidenceLabel(e) {
|
|
95
105
|
return e.kind === "session" ? e.sourceKind ?? "session" : e.kind;
|
|
96
106
|
}
|
|
@@ -125,14 +135,18 @@ function datedEvidence(items, root) {
|
|
|
125
135
|
);
|
|
126
136
|
}
|
|
127
137
|
|
|
128
|
-
// src/
|
|
138
|
+
// src/hook-state.ts
|
|
129
139
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
130
140
|
import { homedir } from "node:os";
|
|
131
141
|
import { dirname, join } from "node:path";
|
|
132
142
|
var MAX_SESSIONS = 40;
|
|
133
143
|
var MAX_FILES = 200;
|
|
144
|
+
var MAX_SHOWN = 400;
|
|
134
145
|
function statePath(home = homedir()) {
|
|
135
|
-
return join(home, ".evrex", "
|
|
146
|
+
return join(home, ".evrex", "hook-state.json");
|
|
147
|
+
}
|
|
148
|
+
function recordKey(e) {
|
|
149
|
+
return `${e.kind}:${e.refId}`;
|
|
136
150
|
}
|
|
137
151
|
function readState(path) {
|
|
138
152
|
try {
|
|
@@ -140,7 +154,16 @@ function readState(path) {
|
|
|
140
154
|
if (!parsed || typeof parsed !== "object" || typeof parsed.sessions !== "object") {
|
|
141
155
|
return { sessions: {} };
|
|
142
156
|
}
|
|
143
|
-
|
|
157
|
+
const sessions = {};
|
|
158
|
+
for (const [id, s] of Object.entries(parsed.sessions ?? {})) {
|
|
159
|
+
if (!s || typeof s !== "object") continue;
|
|
160
|
+
sessions[id] = {
|
|
161
|
+
at: typeof s.at === "string" ? s.at : (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
162
|
+
files: Array.isArray(s.files) ? s.files.filter((f) => typeof f === "string") : [],
|
|
163
|
+
shown: Array.isArray(s.shown) ? s.shown.filter((f) => typeof f === "string") : []
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return { sessions };
|
|
144
167
|
} catch {
|
|
145
168
|
return { sessions: {} };
|
|
146
169
|
}
|
|
@@ -148,18 +171,35 @@ function readState(path) {
|
|
|
148
171
|
function alreadySeen(state, sessionId, file) {
|
|
149
172
|
return state.sessions[sessionId]?.files.includes(file) ?? false;
|
|
150
173
|
}
|
|
151
|
-
function
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
...state.sessions,
|
|
156
|
-
[sessionId]: { at: now.toISOString(), files }
|
|
157
|
-
};
|
|
174
|
+
function shownIn(state, sessionId) {
|
|
175
|
+
return new Set(state.sessions[sessionId]?.shown ?? []);
|
|
176
|
+
}
|
|
177
|
+
function prune(sessions) {
|
|
158
178
|
const ordered = Object.entries(sessions).sort(
|
|
159
179
|
(a, b) => Date.parse(b[1].at) - Date.parse(a[1].at)
|
|
160
180
|
);
|
|
161
181
|
return { sessions: Object.fromEntries(ordered.slice(0, MAX_SESSIONS)) };
|
|
162
182
|
}
|
|
183
|
+
function memoryOf(state, sessionId) {
|
|
184
|
+
const existing = state.sessions[sessionId];
|
|
185
|
+
return { at: existing?.at ?? "", files: existing?.files ?? [], shown: existing?.shown ?? [] };
|
|
186
|
+
}
|
|
187
|
+
function remember(state, sessionId, file, now = /* @__PURE__ */ new Date()) {
|
|
188
|
+
const m = memoryOf(state, sessionId);
|
|
189
|
+
const files = m.files.includes(file) ? m.files : [...m.files, file].slice(-MAX_FILES);
|
|
190
|
+
return prune({
|
|
191
|
+
...state.sessions,
|
|
192
|
+
[sessionId]: { ...m, at: now.toISOString(), files }
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
function rememberShown(state, sessionId, keys, now = /* @__PURE__ */ new Date()) {
|
|
196
|
+
const m = memoryOf(state, sessionId);
|
|
197
|
+
const shown = [.../* @__PURE__ */ new Set([...m.shown, ...keys])].slice(-MAX_SHOWN);
|
|
198
|
+
return prune({
|
|
199
|
+
...state.sessions,
|
|
200
|
+
[sessionId]: { ...m, at: now.toISOString(), shown }
|
|
201
|
+
});
|
|
202
|
+
}
|
|
163
203
|
function writeState(path, state) {
|
|
164
204
|
try {
|
|
165
205
|
mkdirSync(dirname(path), { recursive: true });
|
|
@@ -212,7 +252,7 @@ function formatBlock(relPath, evidence, sessions) {
|
|
|
212
252
|
parts.push(
|
|
213
253
|
"EVIDENCE:\n" + evidence.map((e) => {
|
|
214
254
|
const excerpt = e.excerpt.replace(/\s+/g, " ").trim().slice(0, MAX_EXCERPT);
|
|
215
|
-
return `-
|
|
255
|
+
return `- ${handleFor(e)} [${evidenceLabel(e)}${confidenceLabel(e.provenance)}${dateLabel(e.at)}] ${excerpt}`;
|
|
216
256
|
}).join("\n")
|
|
217
257
|
);
|
|
218
258
|
parts.push(
|
|
@@ -256,13 +296,24 @@ async function main() {
|
|
|
256
296
|
evrexApi.search(match.id, questionFor(relPath), [relPath]),
|
|
257
297
|
left()
|
|
258
298
|
);
|
|
259
|
-
const
|
|
299
|
+
const relevant = datedEvidence(found?.evidence ?? [], match.root).filter(
|
|
260
300
|
(e) => (e.provenance?.confidence ?? 1) >= MIN_CONFIDENCE
|
|
261
301
|
);
|
|
262
|
-
|
|
263
|
-
|
|
302
|
+
const shown = shownIn(state, sessionId);
|
|
303
|
+
const evidence = relevant.filter((e) => !shown.has(recordKey(e)));
|
|
304
|
+
if (sessionId) {
|
|
305
|
+
writeState(
|
|
306
|
+
path,
|
|
307
|
+
rememberShown(
|
|
308
|
+
remember(state, sessionId, file),
|
|
309
|
+
sessionId,
|
|
310
|
+
evidence.slice(0, MAX_ITEMS).map(recordKey)
|
|
311
|
+
)
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
if (relevant.length === 0) return;
|
|
264
315
|
let sessions = [];
|
|
265
|
-
const ids = [...new Set(
|
|
316
|
+
const ids = [...new Set(relevant.filter((e) => e.kind === "session").map((e) => e.refId))].slice(
|
|
266
317
|
0,
|
|
267
318
|
MAX_SESSIONS2
|
|
268
319
|
);
|
package/dist/tickets.js
CHANGED
|
@@ -63,7 +63,14 @@ var init_client = __esm({
|
|
|
63
63
|
// Evidence-only retrieval (BM25 + embedding), no LLM synthesis call — see
|
|
64
64
|
// apps/backend/src/query/query.service.ts#search. Used by evrex_search,
|
|
65
65
|
// which wants ranked hits fast, not a synthesized paragraph.
|
|
66
|
-
search: (repoPath, text, filePaths) => post("/search", { repoPath, text, filePaths })
|
|
66
|
+
search: (repoPath, text, filePaths) => post("/search", { repoPath, text, filePaths }),
|
|
67
|
+
// Everything that happened in a repo, newest first, bounded by days — the
|
|
68
|
+
// same query the desktop Timeline screen makes. Sessions and commits
|
|
69
|
+
// interleaved, each with the handle evrex_expand takes.
|
|
70
|
+
feedback: (body) => post("/feedback", body),
|
|
71
|
+
timeline: (repoPath, days) => get(
|
|
72
|
+
`/timeline?repoPath=${encodeURIComponent(repoPath)}&days=${encodeURIComponent(String(days))}`
|
|
73
|
+
)
|
|
67
74
|
};
|
|
68
75
|
}
|
|
69
76
|
});
|
|
@@ -253,9 +260,22 @@ import { fileURLToPath } from "node:url";
|
|
|
253
260
|
// ../../packages/ingest-core/src/types.ts
|
|
254
261
|
var CONVERSATION_KINDS = [
|
|
255
262
|
"claude-code",
|
|
263
|
+
// Xcode's coding assistant — the Claude Agent SDK embedded, writing the
|
|
264
|
+
// identical transcript format under ~/Library/Developer/Xcode. A separate
|
|
265
|
+
// kind because a conversation in an IDE panel is not a CLI session, and the
|
|
266
|
+
// Slack mislabel already taught this list what an absent entry costs.
|
|
267
|
+
"claude-xcode",
|
|
256
268
|
"cursor",
|
|
257
269
|
"codex",
|
|
258
270
|
"gemini",
|
|
271
|
+
// OpenCode keeps its sessions in a SQLite store rather than files; the
|
|
272
|
+
// parser reads the store, so this kind arrives through the same importer
|
|
273
|
+
// as Cursor's.
|
|
274
|
+
"opencode",
|
|
275
|
+
// GitHub Copilot CLI writes an events file per session under ~/.copilot;
|
|
276
|
+
// the coding agent on github.com is a different thing and arrives as a
|
|
277
|
+
// reference through the Agent-Logs-Url trailer, not as this kind.
|
|
278
|
+
"copilot",
|
|
259
279
|
"slack"
|
|
260
280
|
];
|
|
261
281
|
var REFERENCE_KINDS = ["linear", "jira", "confluence"];
|
|
@@ -264,6 +284,9 @@ var SOURCE_KINDS = [
|
|
|
264
284
|
...REFERENCE_KINDS
|
|
265
285
|
];
|
|
266
286
|
|
|
287
|
+
// ../../packages/ingest-core/src/claude-sessions.ts
|
|
288
|
+
var MAX_TRANSCRIPT_BYTES = 256 * 1024 * 1024;
|
|
289
|
+
|
|
267
290
|
// ../../packages/ingest-core/src/sanitize.ts
|
|
268
291
|
var NUL = String.fromCharCode(0);
|
|
269
292
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "evrex-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "MCP server that gives coding agents the recorded reasoning behind a repo: prior decisions, hard constraints, and approaches already rejected.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -19,13 +19,15 @@
|
|
|
19
19
|
"evrex-import": "dist/import.js",
|
|
20
20
|
"evrex-tickets": "dist/tickets.js",
|
|
21
21
|
"evrex-pretool": "dist/pretool.js",
|
|
22
|
-
"evrex-continuity": "dist/continuity.js"
|
|
22
|
+
"evrex-continuity": "dist/continuity.js",
|
|
23
|
+
"evrex-account": "dist/account.js"
|
|
23
24
|
},
|
|
24
25
|
"files": [
|
|
25
26
|
"dist/index.js",
|
|
26
27
|
"dist/hook.js",
|
|
27
28
|
"dist/pretool.js",
|
|
28
29
|
"dist/continuity.js",
|
|
30
|
+
"dist/account.js",
|
|
29
31
|
"dist/capture.js",
|
|
30
32
|
"dist/import.js",
|
|
31
33
|
"dist/tickets.js",
|
|
@@ -34,28 +36,27 @@
|
|
|
34
36
|
"engines": {
|
|
35
37
|
"node": ">=20"
|
|
36
38
|
},
|
|
37
|
-
"scripts": {
|
|
38
|
-
"build": "tsc -p tsconfig.json",
|
|
39
|
-
"bundle": "node scripts/bundle.mjs",
|
|
40
|
-
"dev": "tsc -p tsconfig.json --watch",
|
|
41
|
-
"lint": "eslint .",
|
|
42
|
-
"check-types": "tsc --noEmit",
|
|
43
|
-
"test": "tsc -p tsconfig.json && node --test dist/*.test.js",
|
|
44
|
-
"prepack": "pnpm run bundle"
|
|
45
|
-
},
|
|
46
39
|
"dependencies": {
|
|
47
40
|
"@anthropic-ai/sdk": "^0.72.0",
|
|
48
41
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
|
49
42
|
"zod": "^3.24.1"
|
|
50
43
|
},
|
|
51
44
|
"devDependencies": {
|
|
52
|
-
"@repo/eslint-config": "workspace:*",
|
|
53
|
-
"@repo/ingest-core": "workspace:*",
|
|
54
|
-
"@repo/llm-core": "workspace:*",
|
|
55
|
-
"@repo/typescript-config": "workspace:*",
|
|
56
45
|
"@types/node": "^24.0.0",
|
|
57
46
|
"esbuild": "^0.25.12",
|
|
58
47
|
"eslint": "^9.39.1",
|
|
59
|
-
"typescript": "^5.9.2"
|
|
48
|
+
"typescript": "^5.9.2",
|
|
49
|
+
"@repo/eslint-config": "0.0.0",
|
|
50
|
+
"@repo/ingest-core": "0.0.0",
|
|
51
|
+
"@repo/llm-core": "0.0.0",
|
|
52
|
+
"@repo/typescript-config": "0.0.0"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "tsc -p tsconfig.json",
|
|
56
|
+
"bundle": "node scripts/bundle.mjs",
|
|
57
|
+
"dev": "tsc -p tsconfig.json --watch",
|
|
58
|
+
"lint": "eslint .",
|
|
59
|
+
"check-types": "tsc --noEmit",
|
|
60
|
+
"test": "tsc -p tsconfig.json && node --test dist/*.test.js"
|
|
60
61
|
}
|
|
61
|
-
}
|
|
62
|
+
}
|