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/dist/index.js CHANGED
@@ -54,11 +54,23 @@ var init_client = __esm({
54
54
  commit: (sha) => getOrNull(`/commits/${encodeURIComponent(sha)}`),
55
55
  sessions: (repoPath) => get(`/sessions?repoPath=${encodeURIComponent(repoPath)}`),
56
56
  session: (id) => getOrNull(`/sessions/${encodeURIComponent(id)}`),
57
+ // The paginated transcript — see reads.service.ts#getSessionTurns. `ts, id`
58
+ // ordering server-side makes offsets stable across requests.
59
+ sessionTurns: (id, offset, limit) => getOrNull(
60
+ `/sessions/${encodeURIComponent(id)}/turns?offset=${offset}&limit=${limit}`
61
+ ),
57
62
  ask: (repoPath, text, filePaths) => post("/ask", { repoPath, text, filePaths }),
58
63
  // Evidence-only retrieval (BM25 + embedding), no LLM synthesis call — see
59
64
  // apps/backend/src/query/query.service.ts#search. Used by evrex_search,
60
65
  // which wants ranked hits fast, not a synthesized paragraph.
61
- 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
+ )
62
74
  };
63
75
  }
64
76
  });
@@ -344,7 +356,7 @@ import { z } from "zod";
344
356
 
345
357
  // src/tools.ts
346
358
  init_client();
347
- import { execFileSync } from "node:child_process";
359
+ import { execFileSync as execFileSync2 } from "node:child_process";
348
360
  import { realpathSync } from "node:fs";
349
361
  import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path";
350
362
 
@@ -429,11 +441,13 @@ var MAX_ITEMS_PER_CATEGORY = 6;
429
441
  var EXTRACTION_SYSTEM_PROMPT = [
430
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.",
431
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.',
432
445
  "Only extract items explicitly present in the transcript below. Never invent, infer beyond the text, or pad categories with generic filler.",
433
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.",
434
447
  `Cap each array at ${MAX_ITEMS_PER_CATEGORY} items \u2014 pick the most consequential ones.`,
435
448
  '"atMessage" is the [N] index of the transcript line the item came from.',
436
- '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.'
437
451
  ].join(" ");
438
452
 
439
453
  // ../../packages/llm-core/src/synthesis.ts
@@ -948,6 +962,96 @@ async function synthesize(question, evidence) {
948
962
  return { sentences: result.sentences };
949
963
  }
950
964
 
965
+ // src/hook-runtime.ts
966
+ import { execFileSync } from "node:child_process";
967
+ function dateLabel(at) {
968
+ if (!at) return "";
969
+ const d = new Date(at);
970
+ return Number.isNaN(d.getTime()) ? "" : ` ${d.toISOString().slice(0, 10)}`;
971
+ }
972
+ function localCommitDates(root, shas) {
973
+ const out = /* @__PURE__ */ new Map();
974
+ if (!root || shas.length === 0) return out;
975
+ try {
976
+ const raw = execFileSync("git", ["show", "-s", "--format=%H%x09%cI", ...shas], {
977
+ cwd: root,
978
+ stdio: ["ignore", "pipe", "ignore"],
979
+ timeout: 1500
980
+ }).toString("utf-8");
981
+ for (const line of raw.split("\n")) {
982
+ const [sha, iso] = line.split(" ");
983
+ if (sha && iso) out.set(sha, iso);
984
+ }
985
+ } catch {
986
+ }
987
+ return out;
988
+ }
989
+ function datedEvidence(items, root) {
990
+ const missing = items.filter((e) => !e.at && e.kind === "commit").map((e) => e.refId);
991
+ if (missing.length === 0) return items;
992
+ const dates = localCommitDates(root, missing);
993
+ return items.map(
994
+ (e) => e.at || e.kind !== "commit" ? e : { ...e, at: dates.get(e.refId) ?? void 0 }
995
+ );
996
+ }
997
+ function spentLabel(tokens) {
998
+ if (!tokens || tokens <= 0) return "";
999
+ const short = tokens >= 1e6 ? `${(tokens / 1e6).toFixed(1)}M` : tokens >= 1e3 ? `${Math.round(tokens / 1e3)}k` : String(tokens);
1000
+ return ` \xB7 ${short} spent`;
1001
+ }
1002
+
1003
+ // src/bottle.ts
1004
+ var HUMAN_CAP = 700;
1005
+ var AGENT_CAP = 500;
1006
+ function cap(text, max) {
1007
+ const trimmed = text.trim();
1008
+ return trimmed.length > max ? `${trimmed.slice(0, max - 1)}\u2026` : trimmed;
1009
+ }
1010
+ function renderEntriesToBottle(entries, preamble, budgetChars) {
1011
+ if (entries.length === 0) return "";
1012
+ const kept = [];
1013
+ let spent = 0;
1014
+ let truncated = false;
1015
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
1016
+ const text = entries[i].text;
1017
+ if (spent + text.length > budgetChars) {
1018
+ truncated = true;
1019
+ break;
1020
+ }
1021
+ kept.unshift(text);
1022
+ spent += text.length;
1023
+ }
1024
+ if (kept.length === 0) return "";
1025
+ return [
1026
+ "<evrex-bottle>",
1027
+ preamble,
1028
+ truncated ? "(Older history did not fit and was dropped from the top.)" : "",
1029
+ "",
1030
+ ...kept,
1031
+ "</evrex-bottle>"
1032
+ ].filter((l, i) => l !== "" || i === 3).join("\n");
1033
+ }
1034
+ function bottleFromTurns(turns, who, budgetChars = 12e3) {
1035
+ const entries = [];
1036
+ for (const t of turns) {
1037
+ if (t.isToolOutput) continue;
1038
+ const body = t.body?.trim();
1039
+ if (!body) continue;
1040
+ if (t.role === "engineer") {
1041
+ entries.push({ text: `HUMAN: ${cap(body, HUMAN_CAP)}` });
1042
+ } else if (body.startsWith("[tool_call:")) {
1043
+ entries.push({ text: cap(body.split("\n")[0] ?? body, 110) });
1044
+ } else {
1045
+ entries.push({ text: `ASSISTANT: ${cap(body, AGENT_CAP)}` });
1046
+ }
1047
+ }
1048
+ return renderEntriesToBottle(
1049
+ entries,
1050
+ `The recorded conversation of ${who}, as ingested \u2014 human and assistant messages verbatim, tool work reduced to one-line markers. This is what was said then, not a statement of what is true now: pick up from where it leaves off, verify against the code before asserting anything it claims, and do not re-try what it shows already failing.`,
1051
+ budgetChars
1052
+ );
1053
+ }
1054
+
951
1055
  // src/tools.ts
952
1056
  var MAX_EXCERPT = 220;
953
1057
  function confidenceLabel(p) {
@@ -955,6 +1059,7 @@ function confidenceLabel(p) {
955
1059
  return p.status;
956
1060
  }
957
1061
  var MAX_ITEMS = 5;
1062
+ var INDEX_EXCERPT = 110;
958
1063
  function evidenceLabel(e) {
959
1064
  return e.kind === "session" ? e.sourceKind ?? "session" : e.kind;
960
1065
  }
@@ -987,7 +1092,7 @@ function normalizeRepoRemote(url) {
987
1092
  }
988
1093
  function git(cwd, args) {
989
1094
  try {
990
- return execFileSync("git", args, {
1095
+ return execFileSync2("git", args, {
991
1096
  cwd,
992
1097
  maxBuffer: 1024 * 1024 * 8,
993
1098
  stdio: ["ignore", "pipe", "ignore"]
@@ -1054,11 +1159,12 @@ async function resolveRepos() {
1054
1159
  async function evrexWhy(filePath, question) {
1055
1160
  const { repoRef, root } = resolveRepoContext(filePath);
1056
1161
  const text = question ?? `What decisions, constraints, and rejected approaches apply to ${filePath}?`;
1057
- const { evidence } = await evrexApi.search(repoRef, text, [
1162
+ const { evidence: rawEvidence } = await evrexApi.search(repoRef, text, [
1058
1163
  toRepoRelative(isAbsolute(filePath) ? filePath : resolvePath(process.cwd(), filePath), root)
1059
1164
  ]);
1165
+ const evidence = datedEvidence(rawEvidence, root);
1060
1166
  if (evidence.length === 0) {
1061
- return `No recorded reasoning found for ${filePath}. Nothing in the indexed sessions or commit history covers this file's design.`;
1167
+ return `No recorded reasoning found for ${filePath}. Nothing in the indexed sessions or commit history covers this file's design. This is an answer, not a failure \u2014 and not a reason to stop: carry on and work it out from the code as you would have without this tool, and say the history does not cover it rather than implying there was nothing to find.`;
1062
1168
  }
1063
1169
  const sessionIds = [...new Set(evidence.filter((e) => e.kind === "session").map((e) => e.refId))];
1064
1170
  const sessions = (await Promise.all(sessionIds.map((id) => evrexApi.session(id)))).filter(
@@ -1069,28 +1175,60 @@ async function evrexWhy(filePath, question) {
1069
1175
  const byRelevance = (items) => [...items].sort(
1070
1176
  (a, b) => Number(aboutTarget(b)) - Number(aboutTarget(a))
1071
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
+ );
1072
1185
  const rejected = byRelevance(sessions.flatMap((s) => s.rejected)).slice(0, MAX_ITEMS);
1073
1186
  const constraints = byRelevance(sessions.flatMap((s) => s.constraints)).slice(0, MAX_ITEMS);
1074
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})`;
1075
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
+ }
1076
1200
  const heuristic = sessions.some((s) => s.insightsSource === "heuristic");
1077
- if (rejected.length > 0) {
1201
+ if (rejected.length + statedRejected.length > 0) {
1078
1202
  parts.push(
1079
- "REJECTED APPROACHES (do not re-propose these without new information):\n" + rejected.map((r) => `- ${r.title} \u2014 ${r.reason}${r.tradeoff ? ` (tradeoff: ${r.tradeoff})` : ""}`).join("\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")
1080
1207
  );
1081
1208
  }
1082
- if (constraints.length > 0) {
1083
- parts.push("CONSTRAINTS:\n" + constraints.map((c) => `- [${c.source}] ${c.text}`).join("\n"));
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
+ );
1084
1213
  }
1085
- if (decisions.length > 0) {
1086
- parts.push("PRIOR DECISIONS:\n" + decisions.map((d) => `- ${d.title}: ${d.detail}`).join("\n"));
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
+ );
1218
+ }
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
+ );
1087
1225
  }
1088
1226
  if (heuristic && (rejected.length > 0 || constraints.length > 0 || decisions.length > 0)) {
1089
1227
  parts.push(
1090
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."
1091
1229
  );
1092
1230
  }
1093
- 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;
1094
1232
  if (synthesisEnabled()) {
1095
1233
  const answer = await synthesize(text, evidence);
1096
1234
  parts.push(
@@ -1098,11 +1236,11 @@ async function evrexWhy(filePath, question) {
1098
1236
  );
1099
1237
  } else {
1100
1238
  parts.push(
1101
- hasBlocks ? "HOW TO USE THIS: treat the constraints and rejected approaches above as binding \u2014 they are what this team already decided, not suggestions. Do not re-propose a rejected approach unless you have new information that specifically invalidates the stated reason, and say so if you do. Answer the user from the evidence below; if it does not actually cover their question, say that rather than inferring." : "HOW TO USE THIS: no decisions, constraints or rejected approaches were extracted for this file \u2014 only the raw evidence below. Treat it as history to read, not as settled policy, and say so if it does not cover the question."
1239
+ hasBlocks ? "HOW TO USE THIS: treat the constraints and rejected approaches above as binding \u2014 they are what this team already decided, not suggestions. Do not re-propose a rejected approach unless you have new information that specifically invalidates the stated reason, and say so if you do.\nThis is a record of what was said, dated, not a statement of what is true now. Two rules follow, and skipping them is measurably worse than not asking at all: check any claim you are about to make against the code before you make it, and where two records disagree prefer the later one \u2014 a decision here may have been reversed by a commit further down this list. If the evidence does not cover the question, say so rather than inferring." : "HOW TO USE THIS: no decisions, constraints or rejected approaches were extracted for this file \u2014 only the raw evidence below, dated. Treat it as history to read, not as settled policy: verify against the code anything you intend to assert, prefer a later record to an earlier one, and say plainly if it does not cover the question."
1102
1240
  );
1103
1241
  }
1104
1242
  const evidenceLines = evidence.slice(0, MAX_ITEMS).map((e) => {
1105
- return `- [${evidenceLabel(e)} ${confidenceLabel(e.provenance)}] ${truncate(e.excerpt, MAX_EXCERPT)}`;
1243
+ return `- [${evidenceLabel(e)} ${confidenceLabel(e.provenance)}${dateLabel(e.at)}] ${truncate(e.excerpt, MAX_EXCERPT)}`;
1106
1244
  });
1107
1245
  parts.push(`EVIDENCE:
1108
1246
  ${evidenceLines.join("\n")}`);
@@ -1124,15 +1262,219 @@ async function evrexSearch(query) {
1124
1262
  })
1125
1263
  );
1126
1264
  const results = perRepo.flat();
1127
- if (results.length === 0) return `No matches for "${query}" across indexed sessions and commits.`;
1265
+ if (results.length === 0) {
1266
+ return `No matches for "${query}" across indexed sessions and commits. Nothing was recorded on this, which is an answer rather than an error. Continue with normal exploration \u2014 read the code, git log, git blame \u2014 and answer from that, noting that the reasoning was never written down.`;
1267
+ }
1128
1268
  const rank = (e) => e.provenance.status === "verified" ? 1 : e.provenance.confidence ?? 0;
1129
1269
  results.sort((a, b) => rank(b.e) - rank(a.e));
1130
1270
  const lines = results.slice(0, MAX_ITEMS * 2).map(({ repo, e }) => {
1131
1271
  const conf = confidenceLabel(e.provenance);
1132
1272
  const repoTag = multiRepo ? ` \xB7 ${repo.name}` : "";
1133
- return `- [${evidenceLabel(e)} ${e.refId.slice(0, 8)} ${conf}${repoTag}] ${truncate(e.excerpt.replace(/\s+/g, " ").trim(), MAX_EXCERPT)}`;
1273
+ const handle = e.kind === "commit" ? `commit:${e.refId.slice(0, 12)}` : `${e.kind}:${e.refId}`;
1274
+ return `- ${handle} [${evidenceLabel(e)} ${conf.trim()}${dateLabel(e.at)}${spentLabel(e.spentTokens)}${repoTag}] ${truncate(e.excerpt.replace(/\s+/g, " ").trim(), INDEX_EXCERPT)}`;
1134
1275
  });
1135
- return lines.join("\n");
1276
+ return [
1277
+ ...lines,
1278
+ "",
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."
1280
+ ].join("\n");
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
+ }
1339
+ async function evrexExpand(handles) {
1340
+ if (handles.length === 0) return "No handles given. Pass ids from evrex_search, e.g. commit:069a0b5.";
1341
+ const parts = [];
1342
+ for (const handle of handles.slice(0, MAX_ITEMS)) {
1343
+ const [kind, ...rest] = handle.split(":");
1344
+ const id = rest.join(":");
1345
+ if (!id) {
1346
+ parts.push(`${handle}: not a handle. Expected kind:id, as evrex_search prints it.`);
1347
+ continue;
1348
+ }
1349
+ if (kind === "commit") {
1350
+ parts.push(`${await evrexCommitContext(id)}
1351
+
1352
+ Open in Evrex: evrex://open?handle=commit:${id}`);
1353
+ continue;
1354
+ }
1355
+ const session = await evrexApi.session(id);
1356
+ if (!session) {
1357
+ parts.push(`${handle}: no such record, or it was never uploaded.`);
1358
+ continue;
1359
+ }
1360
+ const block = [
1361
+ `${handle.toUpperCase()}: ${session.intent}`,
1362
+ `Open in Evrex: evrex://open?handle=session:${id}`
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);
1375
+ if (session.rejected.length > 0) {
1376
+ block.push(
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")
1378
+ );
1379
+ }
1380
+ if (session.constraints.length > 0) {
1381
+ block.push(
1382
+ "CONSTRAINTS:\n" + session.constraints.slice(0, MAX_ITEMS).map((c) => `- [${c.source}] ${c.text}`).join("\n")
1383
+ );
1384
+ }
1385
+ if (session.decisions.length > 0) {
1386
+ block.push(
1387
+ "PRIOR DECISIONS:\n" + session.decisions.slice(0, MAX_ITEMS).map((d) => `- ${d.title}: ${d.detail}`).join("\n")
1388
+ );
1389
+ }
1390
+ const procedures = proceduresBlock(session.procedures ?? []);
1391
+ if (procedures) block.push(procedures);
1392
+ parts.push(block.join("\n"));
1393
+ }
1394
+ parts.push(
1395
+ "This is what was said, dated \u2014 not what is true now. Verify against the code anything you are about to assert, and prefer a later record where two disagree."
1396
+ );
1397
+ return parts.join("\n\n");
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
+ }
1439
+ var BOTTLE_PAGE = 200;
1440
+ var BOTTLE_TAIL_TURNS = 400;
1441
+ async function evrexBottle(sessionRef) {
1442
+ const id = sessionRef.startsWith("session:") ? sessionRef.slice("session:".length) : sessionRef;
1443
+ if (!id.trim()) return "Pass a session id or a session:<id> handle from evrex_search.";
1444
+ const first = await evrexApi.sessionTurns(id, 0, 1);
1445
+ if (!first) return `No session found for ${id}, or it was never uploaded.`;
1446
+ if (first.total === 0) return `Session ${id} is recorded but has no stored turns to render.`;
1447
+ const from = Math.max(0, first.total - BOTTLE_TAIL_TURNS);
1448
+ const pages = await Promise.all([
1449
+ evrexApi.sessionTurns(id, from, BOTTLE_PAGE),
1450
+ first.total - from > BOTTLE_PAGE ? evrexApi.sessionTurns(id, from + BOTTLE_PAGE, BOTTLE_PAGE) : Promise.resolve(null)
1451
+ ]);
1452
+ const turns = pages.flatMap((p) => p?.turns ?? []);
1453
+ const session = await evrexApi.session(id);
1454
+ const who = session ? `session ${id.slice(0, 8)} ("${session.intent}")` : `session ${id.slice(0, 8)}`;
1455
+ const bottle = bottleFromTurns(turns, who);
1456
+ if (!bottle) return `Session ${id} holds only tool traffic \u2014 nothing conversational to render.`;
1457
+ return [
1458
+ bottle,
1459
+ `For what it concluded \u2014 decisions, constraints, rejected approaches \u2014 use evrex_expand ["session:${id}"].`
1460
+ ].join("\n\n");
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");
1136
1478
  }
1137
1479
  async function evrexCommitContext(sha) {
1138
1480
  const commit = await evrexApi.commit(sha);
@@ -1141,6 +1483,8 @@ async function evrexCommitContext(sha) {
1141
1483
  `COMMIT ${commit.sha.slice(0, 8)}: ${commit.message}${commit.body ? `
1142
1484
  ${truncate(commit.body, MAX_EXCERPT)}` : ""}`
1143
1485
  ];
1486
+ const commitFeedback = feedbackLines(commit.feedback);
1487
+ if (commitFeedback) parts.push(commitFeedback);
1144
1488
  if (commit.link) {
1145
1489
  const session = await evrexApi.session(commit.link.sessionId);
1146
1490
  const status = commit.link.provenance.status;
@@ -1165,9 +1509,16 @@ ${truncate(commit.body, MAX_EXCERPT)}` : ""}`
1165
1509
  }
1166
1510
  parts.push(`OUTCOME: ${truncate(session.outcome, MAX_EXCERPT)}`);
1167
1511
  }
1168
- } else {
1512
+ } else if (!commit.agentTrailers?.length) {
1169
1513
  parts.push("No session linked \u2014 no recorded reasoning behind this commit.");
1170
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
+ }
1171
1522
  if (commit.relatedCommitIds.length > 0) {
1172
1523
  const relatedShas = commit.relatedCommitIds.slice(0, MAX_ITEMS);
1173
1524
  const related = (await Promise.all(relatedShas.map((s) => evrexApi.commit(s)))).filter(
@@ -1181,7 +1532,7 @@ ${lines.join("\n")}`);
1181
1532
  }
1182
1533
 
1183
1534
  // src/index.ts
1184
- var VERSION = "0.4.0";
1535
+ var VERSION = "0.8.0";
1185
1536
  var server = new McpServer({ name: "evrex", version: VERSION });
1186
1537
  server.registerTool(
1187
1538
  "evrex_why",
@@ -1212,6 +1563,67 @@ server.registerTool(
1212
1563
  return { content: [{ type: "text", text }] };
1213
1564
  }
1214
1565
  );
1566
+ server.registerTool(
1567
+ "evrex_expand",
1568
+ {
1569
+ title: "Read Evrex records in full",
1570
+ description: "The second half of evrex_search. That returns an index of gists with a handle on each line (`commit:069a0b5`, `slack:a1b2\u2026`); this returns the full record for the handles you pick \u2014 the whole excerpt plus any decisions, constraints and rejected approaches attached to it. Batch the handles you actually want in one call. Expanding every result costs more than a single-shot search would have; expanding the two that look like the answer costs far less, which is the entire point of the split. If nothing in the index looked relevant, say the record does not cover it rather than expanding on spec.",
1571
+ inputSchema: {
1572
+ handles: z.array(z.string()).describe("Handles exactly as evrex_search printed them, e.g. ['commit:069a0b5']")
1573
+ }
1574
+ },
1575
+ async ({ handles }) => {
1576
+ const text = await evrexExpand(handles);
1577
+ return { content: [{ type: "text", text }] };
1578
+ }
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
+ );
1613
+ server.registerTool(
1614
+ "evrex_bottle",
1615
+ {
1616
+ title: "Read a session's conversation, ready to continue",
1617
+ description: "The recorded conversation of an ingested session \u2014 a teammate's, or an earlier one of your own \u2014 with the tool traffic wrung out: human and assistant messages verbatim, each tool call one line, tool output dropped. Use it to pick up unfinished work mid-flow ('continue Jeff's session'): the last exchanges carry the plan half-stated and the instruction not yet acted on, which the extracted insights do not. For what a session concluded rather than how it went, use evrex_expand instead. Takes a session id or a session:<id> handle from evrex_search.",
1618
+ inputSchema: {
1619
+ session: z.string().describe("Session id, or a session:<id> handle as evrex_search prints it")
1620
+ }
1621
+ },
1622
+ async ({ session }) => {
1623
+ const text = await evrexBottle(session);
1624
+ return { content: [{ type: "text", text }] };
1625
+ }
1626
+ );
1215
1627
  server.registerTool(
1216
1628
  "evrex_commit_context",
1217
1629
  {