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/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" + 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")
1177
1207
  );
1178
1208
  }
1179
- if (constraints.length > 0) {
1180
- 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
+ );
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
- if (decisions.length > 0) {
1183
- parts.push("PRIOR DECISIONS:\n" + decisions.map((d) => `- ${d.title}: ${d.detail}`).join("\n"));
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 = `${e.kind}:${e.refId.slice(0, 12)}`;
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.4.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/pretool-state.ts
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", "pretool-state.json");
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
- return { sessions: parsed.sessions ?? {} };
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 remember(state, sessionId, file, now = /* @__PURE__ */ new Date()) {
152
- const existing = state.sessions[sessionId];
153
- const files = existing?.files.includes(file) ? existing.files : [...existing?.files ?? [], file].slice(-MAX_FILES);
154
- const sessions = {
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 `- [${evidenceLabel(e)} ${e.refId.slice(0, 8)}${confidenceLabel(e.provenance)}${dateLabel(e.at)}] ${excerpt}`;
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 evidence = datedEvidence(found?.evidence ?? [], match.root).filter(
299
+ const relevant = datedEvidence(found?.evidence ?? [], match.root).filter(
260
300
  (e) => (e.provenance?.confidence ?? 1) >= MIN_CONFIDENCE
261
301
  );
262
- if (sessionId) writeState(path, remember(state, sessionId, file));
263
- if (evidence.length === 0) return;
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(evidence.filter((e) => e.kind === "session").map((e) => e.refId))].slice(
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.7.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
+ }