evrex-mcp 0.7.0 → 0.8.1

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
@@ -637,6 +646,29 @@ ${request2.user}`;
637
646
  // ../../packages/llm-core/src/provider-clients.ts
638
647
  import Anthropic3 from "@anthropic-ai/sdk";
639
648
  var NOOP = { warn: () => void 0, error: () => void 0 };
649
+ var BATCH_POLL_MS = 5e3;
650
+ var BATCH_TIMEOUT_MS = 6 * 60 * 60 * 1e3;
651
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
652
+ function totalize(items, results) {
653
+ for (const item of items) if (!results.has(item.id)) results.set(item.id, null);
654
+ return results;
655
+ }
656
+ function totalizeIds(ids, results) {
657
+ for (const id of ids) if (!results.has(id)) results.set(id, null);
658
+ return results;
659
+ }
660
+ async function pollToCompletion(handle, resolve, sleepFn, onTimeout) {
661
+ const startedAt = Date.now();
662
+ for (; ; ) {
663
+ const resolved = await resolve(handle);
664
+ if (resolved) return resolved;
665
+ if (Date.now() - startedAt > BATCH_TIMEOUT_MS) {
666
+ onTimeout(handle.id);
667
+ return null;
668
+ }
669
+ await sleepFn(BATCH_POLL_MS);
670
+ }
671
+ }
640
672
  var realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
641
673
  var MAX_RETRIES = 4;
642
674
  var RETRYABLE_STATUS = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
@@ -644,12 +676,12 @@ function isExhaustedStatus(status, retriesSpent) {
644
676
  if (status === 401 || status === 403) return true;
645
677
  return status === 429 && retriesSpent;
646
678
  }
647
- async function waitBeforeRetry(response, attempt, sleep) {
679
+ async function waitBeforeRetry(response, attempt, sleep2) {
648
680
  const header = response.headers.get("retry-after") ?? response.headers.get("x-ratelimit-reset-tokens");
649
681
  const seconds = header ? parseDuration(header) : null;
650
682
  const backoff = Math.min(8 * 2 ** attempt, 60);
651
683
  const wait = Math.min(seconds ?? backoff, MAX_RETRY_WAIT_SECONDS);
652
- await sleep(wait * 1e3);
684
+ await sleep2(wait * 1e3);
653
685
  }
654
686
  var MAX_RETRY_WAIT_SECONDS = 90;
655
687
  function parseDuration(value) {
@@ -669,7 +701,7 @@ function parseJsonBody(text) {
669
701
  return null;
670
702
  }
671
703
  }
672
- var AnthropicClient = class {
704
+ var AnthropicClient = class _AnthropicClient {
673
705
  constructor(key, model, logger) {
674
706
  this.model = model;
675
707
  this.logger = logger;
@@ -678,37 +710,47 @@ var AnthropicClient = class {
678
710
  provider = "anthropic";
679
711
  exhausted = false;
680
712
  sdk;
713
+ // The one place a ModelRequest becomes Anthropic's message params, so the
714
+ // single and batch paths cannot drift on the cache breakpoint or the
715
+ // json_schema wiring.
716
+ messageParams(request2) {
717
+ return {
718
+ model: request2.model ?? this.model,
719
+ max_tokens: request2.maxTokens ?? DEFAULT_MAX_TOKENS,
720
+ // The breakpoint sits at the end of the system prompt, which is the
721
+ // only part of these requests that repeats. Rendering order is
722
+ // tools -> system -> messages, so a marker here caches everything
723
+ // ahead of the transcript.
724
+ //
725
+ // Deliberately NOT on the user block. That block is a different
726
+ // transcript chunk on every call, so a breakpoint there would write a
727
+ // fresh cache entry per request and read none of them back — paying
728
+ // the write premium for nothing, which is worse than not caching.
729
+ system: [
730
+ {
731
+ type: "text",
732
+ text: request2.system,
733
+ cache_control: { type: "ephemeral" }
734
+ }
735
+ ],
736
+ messages: [{ role: "user", content: request2.user }],
737
+ ...request2.schema ? {
738
+ output_config: {
739
+ format: { type: "json_schema", schema: request2.schema }
740
+ }
741
+ } : {}
742
+ };
743
+ }
744
+ static messageText(message) {
745
+ return message.content.filter((b) => b.type === "text").map((b) => b.text).join("");
746
+ }
681
747
  async completeJson(request2) {
682
748
  try {
683
- const message = await this.sdk.messages.create({
684
- model: request2.model ?? this.model,
685
- max_tokens: request2.maxTokens ?? DEFAULT_MAX_TOKENS,
686
- // The breakpoint sits at the end of the system prompt, which is the
687
- // only part of these requests that repeats. Rendering order is
688
- // tools -> system -> messages, so a marker here caches everything
689
- // ahead of the transcript.
690
- //
691
- // Deliberately NOT on the user block. That block is a different
692
- // transcript chunk on every call, so a breakpoint there would write a
693
- // fresh cache entry per request and read none of them back — paying
694
- // the write premium for nothing, which is worse than not caching.
695
- system: [
696
- {
697
- type: "text",
698
- text: request2.system,
699
- cache_control: { type: "ephemeral" }
700
- }
701
- ],
702
- messages: [{ role: "user", content: request2.user }],
703
- ...request2.schema ? {
704
- output_config: {
705
- format: { type: "json_schema", schema: request2.schema }
706
- }
707
- } : {}
708
- });
749
+ const message = await this.sdk.messages.create(
750
+ this.messageParams(request2)
751
+ );
709
752
  this.reportCacheUsage(message.usage);
710
- const text = message.content.filter((b) => b.type === "text").map((b) => b.text).join("");
711
- return parseJsonBody(text);
753
+ return parseJsonBody(_AnthropicClient.messageText(message));
712
754
  } catch (err) {
713
755
  const status = err?.status;
714
756
  if (typeof status === "number" && isExhaustedStatus(status, true)) {
@@ -718,6 +760,58 @@ var AnthropicClient = class {
718
760
  return null;
719
761
  }
720
762
  }
763
+ async submitBatch(items) {
764
+ if (items.length === 0) return null;
765
+ try {
766
+ const created = await this.sdk.messages.batches.create({
767
+ requests: items.map((item) => ({
768
+ custom_id: item.id,
769
+ params: this.messageParams(item.request)
770
+ }))
771
+ });
772
+ return { provider: "anthropic", id: created.id, itemIds: items.map((i) => i.id) };
773
+ } catch (err) {
774
+ const status = err?.status;
775
+ if (typeof status === "number" && isExhaustedStatus(status, true)) {
776
+ this.exhausted = true;
777
+ }
778
+ this.logger.warn(`anthropic batch submit failed: ${describeError(err)}`);
779
+ return null;
780
+ }
781
+ }
782
+ async resolveBatch(handle) {
783
+ try {
784
+ const batch = await this.sdk.messages.batches.retrieve(handle.id);
785
+ if (batch.processing_status !== "ended") return null;
786
+ const results = /* @__PURE__ */ new Map();
787
+ for await (const entry of await this.sdk.messages.batches.results(
788
+ handle.id
789
+ )) {
790
+ results.set(
791
+ entry.custom_id,
792
+ entry.result.type === "succeeded" ? parseJsonBody(_AnthropicClient.messageText(entry.result.message)) : null
793
+ );
794
+ }
795
+ return totalizeIds(handle.itemIds, results);
796
+ } catch (err) {
797
+ this.logger.warn(`anthropic batch resolve failed: ${describeError(err)}`);
798
+ return totalizeIds(handle.itemIds, /* @__PURE__ */ new Map());
799
+ }
800
+ }
801
+ async completeJsonBatch(items) {
802
+ if (items.length === 0) return /* @__PURE__ */ new Map();
803
+ const handle = await this.submitBatch(items);
804
+ if (!handle) return totalize(items, /* @__PURE__ */ new Map());
805
+ const resolved = await pollToCompletion(
806
+ handle,
807
+ (h) => this.resolveBatch(h),
808
+ sleep,
809
+ (id) => this.logger.warn(
810
+ `anthropic batch ${id} did not finish within the cap; the sessions it covers stay unextracted until the next pass`
811
+ )
812
+ );
813
+ return resolved ?? totalize(items, /* @__PURE__ */ new Map());
814
+ }
721
815
  /**
722
816
  * Says out loud whether the cache was actually used.
723
817
  *
@@ -744,12 +838,12 @@ var AnthropicClient = class {
744
838
  );
745
839
  }
746
840
  };
747
- var GoogleClient = class {
748
- constructor(key, model, logger, sleep = realSleep) {
841
+ var GoogleClient = class _GoogleClient {
842
+ constructor(key, model, logger, sleep2 = realSleep) {
749
843
  this.key = key;
750
844
  this.model = model;
751
845
  this.logger = logger;
752
- this.sleep = sleep;
846
+ this.sleep = sleep2;
753
847
  }
754
848
  provider = "google";
755
849
  exhausted = false;
@@ -792,56 +886,129 @@ ${JSON.stringify(request2.schema)}` : "";
792
886
  return null;
793
887
  }
794
888
  }
889
+ static GEMINI_BASE = "https://generativelanguage.googleapis.com/v1beta";
890
+ async submitBatch(items) {
891
+ if (items.length === 0) return null;
892
+ const model = items[0].request.model ?? this.model;
893
+ try {
894
+ const create = await fetch(
895
+ `${_GoogleClient.GEMINI_BASE}/models/${encodeURIComponent(model)}:batchGenerateContent`,
896
+ {
897
+ method: "POST",
898
+ headers: {
899
+ "Content-Type": "application/json",
900
+ "x-goog-api-key": this.key
901
+ },
902
+ body: JSON.stringify({
903
+ batch: {
904
+ display_name: "evrex-extraction",
905
+ input_config: {
906
+ requests: {
907
+ requests: items.map((item) => {
908
+ const shape = item.request.schema ? `
909
+
910
+ Respond with JSON matching exactly this schema:
911
+ ${JSON.stringify(item.request.schema)}` : "";
912
+ return {
913
+ request: {
914
+ systemInstruction: {
915
+ parts: [{ text: item.request.system + shape }]
916
+ },
917
+ contents: [
918
+ { role: "user", parts: [{ text: item.request.user }] }
919
+ ],
920
+ generationConfig: { responseMimeType: "application/json" }
921
+ },
922
+ metadata: { key: item.id }
923
+ };
924
+ })
925
+ }
926
+ }
927
+ }
928
+ })
929
+ }
930
+ );
931
+ if (!create.ok) {
932
+ if (isExhaustedStatus(create.status, true)) this.exhausted = true;
933
+ this.logger.warn(
934
+ `google batch submit failed: ${create.status} ${(await create.text()).slice(0, 200)}`
935
+ );
936
+ return null;
937
+ }
938
+ const name = (await create.json()).name;
939
+ if (!name) {
940
+ this.logger.warn("google batch: no job name returned");
941
+ return null;
942
+ }
943
+ return { provider: "google", id: name, itemIds: items.map((i) => i.id) };
944
+ } catch (err) {
945
+ this.logger.warn(`google batch submit failed: ${describeError(err)}`);
946
+ return null;
947
+ }
948
+ }
949
+ async resolveBatch(handle) {
950
+ try {
951
+ const poll = await fetch(`${_GoogleClient.GEMINI_BASE}/${handle.id}`, {
952
+ headers: { "x-goog-api-key": this.key }
953
+ });
954
+ if (!poll.ok) {
955
+ this.logger.warn(`google batch poll failed: ${poll.status}`);
956
+ return totalizeIds(handle.itemIds, /* @__PURE__ */ new Map());
957
+ }
958
+ const job = await poll.json();
959
+ const state = job.state ?? "";
960
+ if (state === "JOB_STATE_FAILED" || state === "JOB_STATE_CANCELLED" || state === "JOB_STATE_EXPIRED") {
961
+ this.logger.warn(`google batch ${handle.id} ended ${state}`);
962
+ return totalizeIds(handle.itemIds, /* @__PURE__ */ new Map());
963
+ }
964
+ if (state !== "JOB_STATE_SUCCEEDED") return null;
965
+ const inlined = job.response?.inlinedResponses ?? [];
966
+ const results = /* @__PURE__ */ new Map();
967
+ handle.itemIds.forEach((id, i) => {
968
+ const text = inlined[i]?.response?.candidates?.[0]?.content?.parts?.map((p) => p.text ?? "").join("");
969
+ results.set(id, text ? parseJsonBody(text) : null);
970
+ });
971
+ return totalizeIds(handle.itemIds, results);
972
+ } catch (err) {
973
+ this.logger.warn(`google batch resolve failed: ${describeError(err)}`);
974
+ return totalizeIds(handle.itemIds, /* @__PURE__ */ new Map());
975
+ }
976
+ }
977
+ async completeJsonBatch(items) {
978
+ if (items.length === 0) return /* @__PURE__ */ new Map();
979
+ const handle = await this.submitBatch(items);
980
+ if (!handle) return totalize(items, /* @__PURE__ */ new Map());
981
+ const resolved = await pollToCompletion(
982
+ handle,
983
+ (h) => this.resolveBatch(h),
984
+ this.sleep,
985
+ (id) => this.logger.warn(
986
+ `google batch ${id} did not finish within the cap; leaving its sessions for a later pass`
987
+ )
988
+ );
989
+ return resolved ?? totalize(items, /* @__PURE__ */ new Map());
990
+ }
795
991
  };
796
992
  var OpenAiCompatibleClient = class {
797
- constructor(provider, baseUrl, key, model, logger, sleep = realSleep) {
993
+ constructor(provider, baseUrl, key, model, logger, sleep2 = realSleep) {
798
994
  this.provider = provider;
799
995
  this.baseUrl = baseUrl;
800
996
  this.key = key;
801
997
  this.model = model;
802
998
  this.logger = logger;
803
- this.sleep = sleep;
999
+ this.sleep = sleep2;
1000
+ if (this.provider === "openai") {
1001
+ this.completeJsonBatch = (items) => this.runBatch(items);
1002
+ }
804
1003
  }
805
1004
  exhausted = false;
1005
+ completeJsonBatch;
806
1006
  async completeJson(request2, attempt = 0) {
807
- const strict = this.provider === "openai";
808
- const shape = request2.schema && !strict ? `
809
-
810
- Respond with JSON matching exactly this schema:
811
- ${JSON.stringify(request2.schema)}` : "";
812
1007
  try {
813
1008
  const res = await fetch(`${this.baseUrl}/chat/completions`, {
814
1009
  method: "POST",
815
- headers: {
816
- "Content-Type": "application/json",
817
- // A local server does not want one, and some reject an empty bearer
818
- // token outright.
819
- ...this.key ? { Authorization: `Bearer ${this.key}` } : {}
820
- },
821
- body: JSON.stringify({
822
- model: request2.model ?? this.model,
823
- messages: [
824
- { role: "system", content: request2.system + shape },
825
- { role: "user", content: request2.user }
826
- ],
827
- ...request2.schema && strict ? {
828
- response_format: {
829
- type: "json_schema",
830
- json_schema: {
831
- name: "evrex_result",
832
- strict: true,
833
- schema: request2.schema
834
- }
835
- }
836
- } : { response_format: { type: "json_object" } },
837
- // Extraction is a reading task with a right answer, not a writing
838
- // task, and a default sampling temperature makes it a different
839
- // answer each run: one model scored 53% and then 20% on the same ten
840
- // sessions before this was pinned. Indexing twice must not produce
841
- // two different histories.
842
- temperature: 0,
843
- stream: false
844
- })
1010
+ headers: this.authHeaders(),
1011
+ body: JSON.stringify(this.chatBody(request2))
845
1012
  });
846
1013
  if (RETRYABLE_STATUS.has(res.status) && attempt < MAX_RETRIES) {
847
1014
  await waitBeforeRetry(res, attempt, this.sleep);
@@ -862,6 +1029,147 @@ ${JSON.stringify(request2.schema)}` : "";
862
1029
  return null;
863
1030
  }
864
1031
  }
1032
+ authHeaders() {
1033
+ return {
1034
+ "Content-Type": "application/json",
1035
+ // A local server does not want one, and some reject an empty bearer token.
1036
+ ...this.key ? { Authorization: `Bearer ${this.key}` } : {}
1037
+ };
1038
+ }
1039
+ // One place a ModelRequest becomes the chat body, so completeJson and the
1040
+ // batch path cannot drift on response_format or the pinned temperature.
1041
+ chatBody(request2) {
1042
+ const strict = this.provider === "openai";
1043
+ const shape = request2.schema && !strict ? `
1044
+
1045
+ Respond with JSON matching exactly this schema:
1046
+ ${JSON.stringify(request2.schema)}` : "";
1047
+ return {
1048
+ model: request2.model ?? this.model,
1049
+ messages: [
1050
+ { role: "system", content: request2.system + shape },
1051
+ { role: "user", content: request2.user }
1052
+ ],
1053
+ ...request2.schema && strict ? {
1054
+ response_format: {
1055
+ type: "json_schema",
1056
+ json_schema: {
1057
+ name: "evrex_result",
1058
+ strict: true,
1059
+ schema: request2.schema
1060
+ }
1061
+ }
1062
+ } : { response_format: { type: "json_object" } },
1063
+ // A reading task with a right answer: temperature pinned so indexing
1064
+ // twice is the same history.
1065
+ temperature: 0,
1066
+ stream: false
1067
+ };
1068
+ }
1069
+ async runBatch(items) {
1070
+ const results = /* @__PURE__ */ new Map();
1071
+ if (items.length === 0) return results;
1072
+ const auth = this.key ? { Authorization: `Bearer ${this.key}` } : {};
1073
+ try {
1074
+ const jsonl = items.map(
1075
+ (item) => JSON.stringify({
1076
+ custom_id: item.id,
1077
+ method: "POST",
1078
+ url: "/v1/chat/completions",
1079
+ body: this.chatBody(item.request)
1080
+ })
1081
+ ).join("\n");
1082
+ const form = new FormData();
1083
+ form.append("purpose", "batch");
1084
+ form.append(
1085
+ "file",
1086
+ new Blob([jsonl], { type: "application/jsonl" }),
1087
+ "evrex-batch.jsonl"
1088
+ );
1089
+ const upload = await fetch(`${this.baseUrl}/files`, {
1090
+ method: "POST",
1091
+ headers: auth,
1092
+ body: form
1093
+ });
1094
+ if (!upload.ok) {
1095
+ if (isExhaustedStatus(upload.status, true)) this.exhausted = true;
1096
+ this.logger.warn(
1097
+ `openai batch upload failed: ${upload.status} ${(await upload.text()).slice(0, 200)}`
1098
+ );
1099
+ return totalize(items, results);
1100
+ }
1101
+ const fileId = (await upload.json()).id;
1102
+ if (!fileId) return totalize(items, results);
1103
+ const created = await fetch(`${this.baseUrl}/batches`, {
1104
+ method: "POST",
1105
+ headers: this.authHeaders(),
1106
+ body: JSON.stringify({
1107
+ input_file_id: fileId,
1108
+ endpoint: "/v1/chat/completions",
1109
+ completion_window: "24h"
1110
+ })
1111
+ });
1112
+ if (!created.ok) {
1113
+ this.logger.warn(
1114
+ `openai batch create failed: ${created.status} ${(await created.text()).slice(0, 200)}`
1115
+ );
1116
+ return totalize(items, results);
1117
+ }
1118
+ const batchId = (await created.json()).id;
1119
+ if (!batchId) return totalize(items, results);
1120
+ let status = "";
1121
+ let outputFileId;
1122
+ const startedAt = Date.now();
1123
+ while (status !== "completed") {
1124
+ if (Date.now() - startedAt > BATCH_TIMEOUT_MS) {
1125
+ this.logger.warn(
1126
+ `openai batch ${batchId} did not finish within the cap; leaving its sessions for a later pass`
1127
+ );
1128
+ return totalize(items, results);
1129
+ }
1130
+ await this.sleep(BATCH_POLL_MS);
1131
+ const poll = await fetch(`${this.baseUrl}/batches/${batchId}`, {
1132
+ headers: auth
1133
+ });
1134
+ if (!poll.ok) {
1135
+ this.logger.warn(`openai batch poll failed: ${poll.status}`);
1136
+ return totalize(items, results);
1137
+ }
1138
+ const job = await poll.json();
1139
+ status = job.status ?? "";
1140
+ outputFileId = job.output_file_id;
1141
+ if (status === "failed" || status === "expired" || status === "cancelled") {
1142
+ this.logger.warn(`openai batch ${batchId} ended ${status}`);
1143
+ return totalize(items, results);
1144
+ }
1145
+ }
1146
+ if (!outputFileId) return totalize(items, results);
1147
+ const out = await fetch(`${this.baseUrl}/files/${outputFileId}/content`, {
1148
+ headers: auth
1149
+ });
1150
+ if (!out.ok) {
1151
+ this.logger.warn(`openai batch results fetch failed: ${out.status}`);
1152
+ return totalize(items, results);
1153
+ }
1154
+ for (const line of (await out.text()).split("\n")) {
1155
+ if (!line.trim()) continue;
1156
+ try {
1157
+ const row = JSON.parse(line);
1158
+ const content = row.response?.body?.choices?.[0]?.message?.content;
1159
+ if (row.custom_id) {
1160
+ results.set(
1161
+ row.custom_id,
1162
+ typeof content === "string" ? parseJsonBody(content) : null
1163
+ );
1164
+ }
1165
+ } catch {
1166
+ }
1167
+ }
1168
+ } catch (err) {
1169
+ this.logger.warn(`openai batch failed: ${describeError(err)}`);
1170
+ }
1171
+ return totalize(items, results);
1172
+ }
865
1173
  };
866
1174
  function describeError(err) {
867
1175
  return err instanceof Error ? err.message : String(err);
@@ -873,9 +1181,9 @@ function createModelClient(key, options = {}) {
873
1181
  const model = options.model ?? DEFAULT_MODELS[provider];
874
1182
  const trimmed = key.trim();
875
1183
  if (provider === "anthropic") return new AnthropicClient(trimmed, model, logger);
876
- const sleep = options.sleep ?? realSleep;
1184
+ const sleep2 = options.sleep ?? realSleep;
877
1185
  if (provider === "google")
878
- return new GoogleClient(trimmed, model, logger, sleep);
1186
+ return new GoogleClient(trimmed, model, logger, sleep2);
879
1187
  const cli = CLI_SPECS[provider];
880
1188
  if (cli) return new CliClient(cli, model, logger);
881
1189
  if (provider === "local") {
@@ -885,7 +1193,7 @@ function createModelClient(key, options = {}) {
885
1193
  null,
886
1194
  model,
887
1195
  logger,
888
- sleep
1196
+ sleep2
889
1197
  );
890
1198
  }
891
1199
  const baseUrl = options.baseUrl ?? OPENAI_COMPATIBLE_BASE_URLS[provider];
@@ -896,7 +1204,7 @@ function createModelClient(key, options = {}) {
896
1204
  trimmed,
897
1205
  model,
898
1206
  logger,
899
- sleep
1207
+ sleep2
900
1208
  );
901
1209
  }
902
1210
 
@@ -1166,28 +1474,60 @@ async function evrexWhy(filePath, question) {
1166
1474
  const byRelevance = (items) => [...items].sort(
1167
1475
  (a, b) => Number(aboutTarget(b)) - Number(aboutTarget(a))
1168
1476
  );
1477
+ const commitShas = [...new Set(evidence.filter((e) => e.kind === "commit").map((e) => e.refId))].slice(0, MAX_ITEMS);
1478
+ const commits = (await Promise.all(commitShas.map((sha) => evrexApi.commit(sha).catch(() => null)))).filter(
1479
+ (c) => c !== null && (c.statedInsights?.length ?? 0) > 0
1480
+ );
1481
+ const statedOf = (kind) => commits.flatMap(
1482
+ (c) => (c.statedInsights ?? []).filter((i) => i.kind === kind).map((i) => ({ text: i.text, stated: `commit:${c.sha.slice(0, 12)}` }))
1483
+ );
1169
1484
  const rejected = byRelevance(sessions.flatMap((s) => s.rejected)).slice(0, MAX_ITEMS);
1170
1485
  const constraints = byRelevance(sessions.flatMap((s) => s.constraints)).slice(0, MAX_ITEMS);
1171
1486
  const decisions = byRelevance(sessions.flatMap((s) => s.decisions)).slice(0, MAX_ITEMS);
1487
+ const procedures = byRelevance(sessions.flatMap((s) => s.procedures ?? [])).slice(0, MAX_ITEMS);
1488
+ const statedRejected = statedOf("rejected").slice(0, MAX_ITEMS);
1489
+ const statedConstraints = statedOf("constraint").slice(0, MAX_ITEMS);
1490
+ const statedDecisions = statedOf("decision").slice(0, MAX_ITEMS);
1491
+ const statedLine = (i) => `- ${i.text} (stated in ${i.stated})`;
1172
1492
  const parts = [];
1493
+ const marked = sessions.filter((s) => (s.feedback ?? []).some((f) => f.itemId === null && f.signal !== "helpful"));
1494
+ if (marked.length > 0) {
1495
+ parts.push(
1496
+ marked.map((s) => `session:${s.id} \u2014 ${feedbackLines((s.feedback ?? []).filter((f) => f.itemId === null))}`).join("\n")
1497
+ );
1498
+ }
1173
1499
  const heuristic = sessions.some((s) => s.insightsSource === "heuristic");
1174
- if (rejected.length > 0) {
1500
+ if (rejected.length + statedRejected.length > 0) {
1501
+ parts.push(
1502
+ "REJECTED APPROACHES (do not re-propose these without new information):\n" + [
1503
+ ...rejected.map((r) => `- ${r.title} \u2014 ${r.reason}${r.tradeoff ? ` (tradeoff: ${r.tradeoff})` : ""}${itemMark(sessions.flatMap((s) => s.feedback ?? []), r.id)}`),
1504
+ ...statedRejected.map(statedLine)
1505
+ ].join("\n")
1506
+ );
1507
+ }
1508
+ if (constraints.length + statedConstraints.length > 0) {
1175
1509
  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")
1510
+ "CONSTRAINTS:\n" + [...constraints.map((c) => `- [${c.source}] ${c.text}`), ...statedConstraints.map(statedLine)].join("\n")
1177
1511
  );
1178
1512
  }
1179
- if (constraints.length > 0) {
1180
- parts.push("CONSTRAINTS:\n" + constraints.map((c) => `- [${c.source}] ${c.text}`).join("\n"));
1513
+ if (decisions.length + statedDecisions.length > 0) {
1514
+ parts.push(
1515
+ "PRIOR DECISIONS:\n" + [...decisions.map((d) => `- ${d.title}: ${d.detail}`), ...statedDecisions.map(statedLine)].join("\n")
1516
+ );
1181
1517
  }
1182
- if (decisions.length > 0) {
1183
- parts.push("PRIOR DECISIONS:\n" + decisions.map((d) => `- ${d.title}: ${d.detail}`).join("\n"));
1518
+ const proceduresText = proceduresBlock(procedures);
1519
+ if (proceduresText) parts.push(proceduresText);
1520
+ if (commits.length > 0) {
1521
+ parts.push(
1522
+ `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.`
1523
+ );
1184
1524
  }
1185
1525
  if (heuristic && (rejected.length > 0 || constraints.length > 0 || decisions.length > 0)) {
1186
1526
  parts.push(
1187
1527
  "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
1528
  );
1189
1529
  }
1190
- const hasBlocks = rejected.length > 0 || constraints.length > 0 || decisions.length > 0;
1530
+ const hasBlocks = rejected.length + statedRejected.length > 0 || constraints.length + statedConstraints.length > 0 || decisions.length + statedDecisions.length > 0;
1191
1531
  if (synthesisEnabled()) {
1192
1532
  const answer = await synthesize(text, evidence);
1193
1533
  parts.push(
@@ -1229,7 +1569,7 @@ async function evrexSearch(query) {
1229
1569
  const lines = results.slice(0, MAX_ITEMS * 2).map(({ repo, e }) => {
1230
1570
  const conf = confidenceLabel(e.provenance);
1231
1571
  const repoTag = multiRepo ? ` \xB7 ${repo.name}` : "";
1232
- const handle = `${e.kind}:${e.refId.slice(0, 12)}`;
1572
+ const handle = e.kind === "commit" ? `commit:${e.refId.slice(0, 12)}` : `${e.kind}:${e.refId}`;
1233
1573
  return `- ${handle} [${evidenceLabel(e)} ${conf.trim()}${dateLabel(e.at)}${spentLabel(e.spentTokens)}${repoTag}] ${truncate(e.excerpt.replace(/\s+/g, " ").trim(), INDEX_EXCERPT)}`;
1234
1574
  });
1235
1575
  return [
@@ -1238,6 +1578,63 @@ async function evrexSearch(query) {
1238
1578
  "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
1579
  ].join("\n");
1240
1580
  }
1581
+ var TIMELINE_DEFAULT_DAYS = 7;
1582
+ var TIMELINE_MAX_DAYS = 90;
1583
+ var TIMELINE_DEFAULT_LIMIT = 30;
1584
+ var TIMELINE_MAX_LIMIT = 200;
1585
+ async function evrexTimeline(days = TIMELINE_DEFAULT_DAYS, limit = TIMELINE_DEFAULT_LIMIT) {
1586
+ const window = Math.min(TIMELINE_MAX_DAYS, Math.max(1, Math.floor(days) || TIMELINE_DEFAULT_DAYS));
1587
+ const cap2 = Math.min(TIMELINE_MAX_LIMIT, Math.max(1, Math.floor(limit) || TIMELINE_DEFAULT_LIMIT));
1588
+ const repos = await resolveRepos();
1589
+ if (repos.length === 0) return "No indexed repos found (GET /repos returned none).";
1590
+ const multiRepo = repos.length > 1;
1591
+ const perRepo = await Promise.all(
1592
+ repos.map(async (repo) => {
1593
+ try {
1594
+ const entries = await evrexApi.timeline(repo.id, window);
1595
+ return entries.map((entry) => ({ repo, entry }));
1596
+ } catch {
1597
+ return [];
1598
+ }
1599
+ })
1600
+ );
1601
+ const all = perRepo.flat().sort((a, b) => Date.parse(b.entry.at) - Date.parse(a.entry.at));
1602
+ const where = multiRepo ? "the indexed repos" : repos[0].name;
1603
+ if (all.length === 0) {
1604
+ 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\`.`;
1605
+ }
1606
+ const shown = all.slice(0, cap2);
1607
+ const lines = shown.map(({ repo, entry }) => formatTimelineLine(entry, multiRepo ? repo.name : null));
1608
+ const omitted = all.length - shown.length;
1609
+ return [
1610
+ `Last ${window} day${window === 1 ? "" : "s"} in ${where}, newest first \u2014 ${all.length} record${all.length === 1 ? "" : "s"}${omitted > 0 ? `, ${shown.length} shown` : ""}:`,
1611
+ ...lines,
1612
+ "",
1613
+ (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."
1614
+ ].join("\n");
1615
+ }
1616
+ function formatTimelineLine(entry, repoName) {
1617
+ const handle = entry.kind === "commit" ? `commit:${entry.refId.slice(0, 12)}` : `session:${entry.refId}`;
1618
+ const tags = [entry.kind === "session" ? entry.sourceKind ?? "session" : "commit"];
1619
+ if (entry.kind === "commit") {
1620
+ if (entry.linkStatus === "inferred" && entry.confidence !== void 0) {
1621
+ tags.push(`${Math.round(entry.confidence * 100)}%`);
1622
+ } else if (entry.linkStatus) {
1623
+ tags.push(entry.linkStatus);
1624
+ }
1625
+ }
1626
+ if (entry.author && entry.author !== "Unknown") tags.push(entry.author);
1627
+ if (entry.meta) tags.push(entry.meta);
1628
+ if (repoName) tags.push(repoName);
1629
+ const nest = entry.kind === "commit" && entry.sessionId && entry.linkStatus !== "inferred" ? ` \u21B3 session:${entry.sessionId}` : "";
1630
+ const title = truncate(entry.title.replace(/\s+/g, " ").trim(), INDEX_EXCERPT);
1631
+ return `- ${dateLabel(entry.at).trim() || "undated"} ${handle} [${tags.join(" \xB7 ")}${nest}] ${title}`;
1632
+ }
1633
+ function proceduresBlock(procedures) {
1634
+ if (procedures.length === 0) return "";
1635
+ 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}
1636
+ ${p.steps.slice(0, 12).map((step, i) => ` ${i + 1}. ${step}`).join("\n")}`).join("\n");
1637
+ }
1241
1638
  async function evrexExpand(handles) {
1242
1639
  if (handles.length === 0) return "No handles given. Pass ids from evrex_search, e.g. commit:069a0b5.";
1243
1640
  const parts = [];
@@ -1263,9 +1660,20 @@ Open in Evrex: evrex://open?handle=commit:${id}`);
1263
1660
  `${handle.toUpperCase()}: ${session.intent}`,
1264
1661
  `Open in Evrex: evrex://open?handle=session:${id}`
1265
1662
  ];
1663
+ if (session.parentSessionId) {
1664
+ const what = session.subagent?.agentType ? ` (${session.subagent.agentType})` : "";
1665
+ block.push(`SUB-AGENT${what} of session:${session.parentSessionId} \u2014 expand that for what it was asked to do and what became of it.`);
1666
+ }
1667
+ if (session.subagentIds && session.subagentIds.length > 0) {
1668
+ block.push(
1669
+ `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."
1670
+ );
1671
+ }
1672
+ const fb = feedbackLines(session.feedback);
1673
+ if (fb) block.push(fb);
1266
1674
  if (session.rejected.length > 0) {
1267
1675
  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")
1676
+ "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
1677
  );
1270
1678
  }
1271
1679
  if (session.constraints.length > 0) {
@@ -1278,6 +1686,8 @@ Open in Evrex: evrex://open?handle=commit:${id}`);
1278
1686
  "PRIOR DECISIONS:\n" + session.decisions.slice(0, MAX_ITEMS).map((d) => `- ${d.title}: ${d.detail}`).join("\n")
1279
1687
  );
1280
1688
  }
1689
+ const procedures = proceduresBlock(session.procedures ?? []);
1690
+ if (procedures) block.push(procedures);
1281
1691
  parts.push(block.join("\n"));
1282
1692
  }
1283
1693
  parts.push(
@@ -1285,6 +1695,46 @@ Open in Evrex: evrex://open?handle=commit:${id}`);
1285
1695
  );
1286
1696
  return parts.join("\n\n");
1287
1697
  }
1698
+ var FEEDBACK_SIGNALS = ["helpful", "not_helpful", "stale", "wrong", "superseded"];
1699
+ function feedbackLines(feedback) {
1700
+ if (!feedback || feedback.length === 0) return "";
1701
+ const lines = feedback.slice(0, MAX_ITEMS).map((f) => {
1702
+ const scope = f.itemId ? ` on ${f.itemId}` : "";
1703
+ const by = f.supersededBy ? ` by ${f.supersededBy}` : "";
1704
+ const note = f.note ? `: ${truncate(f.note, 160)}` : "";
1705
+ return `- ${f.signal}${by}${scope}${dateLabel(f.at)}${note}`;
1706
+ });
1707
+ return `READER FEEDBACK (what someone said after reading this \u2014 weigh it above the record's own date):
1708
+ ${lines.join("\n")}`;
1709
+ }
1710
+ function itemMark(feedback, itemId) {
1711
+ const marks = (feedback ?? []).filter((f2) => f2.itemId === itemId && f2.signal !== "helpful");
1712
+ if (marks.length === 0) return "";
1713
+ const f = marks[0];
1714
+ return ` [marked ${f.signal}${f.supersededBy ? ` by ${f.supersededBy}` : ""}${dateLabel(f.at)}${f.note ? `: ${truncate(f.note, 100)}` : ""}]`;
1715
+ }
1716
+ async function evrexFeedback(handle, signal, note, itemId, supersededBy) {
1717
+ if (!FEEDBACK_SIGNALS.includes(signal)) {
1718
+ return `"${signal}" is not a signal. One of: ${FEEDBACK_SIGNALS.join(", ")}.`;
1719
+ }
1720
+ if (signal === "superseded" && !supersededBy) {
1721
+ return `"superseded" names the record that replaced this one \u2014 pass superseded_by with its handle.`;
1722
+ }
1723
+ try {
1724
+ const saved = await evrexApi.feedback({
1725
+ target: handle.trim(),
1726
+ signal,
1727
+ ...note ? { note } : {},
1728
+ ...itemId ? { itemId } : {},
1729
+ ...supersededBy ? { supersededBy } : {}
1730
+ });
1731
+ const where = itemId ? ` (item ${itemId})` : "";
1732
+ 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.";
1733
+ return `Recorded ${saved.signal} on ${handle}${where}${saved.supersededBy ? ` \u2014 superseded by ${saved.supersededBy}` : ""}. ${effect}`;
1734
+ } catch (err) {
1735
+ return `Could not record feedback: ${err instanceof Error ? err.message : String(err)}`;
1736
+ }
1737
+ }
1288
1738
  var BOTTLE_PAGE = 200;
1289
1739
  var BOTTLE_TAIL_TURNS = 400;
1290
1740
  async function evrexBottle(sessionRef) {
@@ -1308,6 +1758,23 @@ async function evrexBottle(sessionRef) {
1308
1758
  `For what it concluded \u2014 decisions, constraints, rejected approaches \u2014 use evrex_expand ["session:${id}"].`
1309
1759
  ].join("\n\n");
1310
1760
  }
1761
+ function statedBlocks(items, handle) {
1762
+ const of = (kind) => items.filter((i) => i.kind === kind).map((i) => `- ${i.text}`);
1763
+ const rejected = of("rejected");
1764
+ const constraints = of("constraint");
1765
+ const decisions = of("decision");
1766
+ if (rejected.length + constraints.length + decisions.length === 0) return "";
1767
+ const parts = [
1768
+ `STATED IN THE COMMIT (${handle} wrote these as trailers at commit time \u2014 the committer's own account, not extracted by a model):`
1769
+ ];
1770
+ if (rejected.length) parts.push(`REJECTED:
1771
+ ${rejected.join("\n")}`);
1772
+ if (constraints.length) parts.push(`CONSTRAINTS:
1773
+ ${constraints.join("\n")}`);
1774
+ if (decisions.length) parts.push(`DECISIONS:
1775
+ ${decisions.join("\n")}`);
1776
+ return parts.join("\n");
1777
+ }
1311
1778
  async function evrexCommitContext(sha) {
1312
1779
  const commit = await evrexApi.commit(sha);
1313
1780
  if (!commit) return `No commit found for sha ${sha}.`;
@@ -1315,6 +1782,8 @@ async function evrexCommitContext(sha) {
1315
1782
  `COMMIT ${commit.sha.slice(0, 8)}: ${commit.message}${commit.body ? `
1316
1783
  ${truncate(commit.body, MAX_EXCERPT)}` : ""}`
1317
1784
  ];
1785
+ const commitFeedback = feedbackLines(commit.feedback);
1786
+ if (commitFeedback) parts.push(commitFeedback);
1318
1787
  if (commit.link) {
1319
1788
  const session = await evrexApi.session(commit.link.sessionId);
1320
1789
  const status = commit.link.provenance.status;
@@ -1339,9 +1808,16 @@ ${truncate(commit.body, MAX_EXCERPT)}` : ""}`
1339
1808
  }
1340
1809
  parts.push(`OUTCOME: ${truncate(session.outcome, MAX_EXCERPT)}`);
1341
1810
  }
1342
- } else {
1811
+ } else if (!commit.agentTrailers?.length) {
1343
1812
  parts.push("No session linked \u2014 no recorded reasoning behind this commit.");
1344
1813
  }
1814
+ const stated = statedBlocks(commit.statedInsights ?? [], `commit:${commit.sha.slice(0, 12)}`);
1815
+ if (stated) parts.push(stated);
1816
+ for (const t of commit.agentTrailers ?? []) {
1817
+ parts.push(
1818
+ `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.`
1819
+ );
1820
+ }
1345
1821
  if (commit.relatedCommitIds.length > 0) {
1346
1822
  const relatedShas = commit.relatedCommitIds.slice(0, MAX_ITEMS);
1347
1823
  const related = (await Promise.all(relatedShas.map((s) => evrexApi.commit(s)))).filter(
@@ -1355,7 +1831,7 @@ ${lines.join("\n")}`);
1355
1831
  }
1356
1832
 
1357
1833
  // src/index.ts
1358
- var VERSION = "0.4.0";
1834
+ var VERSION = "0.8.0";
1359
1835
  var server = new McpServer({ name: "evrex", version: VERSION });
1360
1836
  server.registerTool(
1361
1837
  "evrex_why",
@@ -1400,6 +1876,39 @@ server.registerTool(
1400
1876
  return { content: [{ type: "text", text }] };
1401
1877
  }
1402
1878
  );
1879
+ server.registerTool(
1880
+ "evrex_timeline",
1881
+ {
1882
+ title: "What happened in this repo lately",
1883
+ 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.",
1884
+ inputSchema: {
1885
+ days: z.number().int().min(1).max(90).optional().describe("How far back to look, in days. Default 7, maximum 90."),
1886
+ limit: z.number().int().min(1).max(200).optional().describe("At most this many lines, newest first. Default 30, maximum 200.")
1887
+ }
1888
+ },
1889
+ async ({ days, limit }) => {
1890
+ const text = await evrexTimeline(days, limit);
1891
+ return { content: [{ type: "text", text }] };
1892
+ }
1893
+ );
1894
+ server.registerTool(
1895
+ "evrex_feedback",
1896
+ {
1897
+ title: "Say something about a record after reading it",
1898
+ 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.",
1899
+ inputSchema: {
1900
+ handle: z.string().describe("session:<id> or commit:<sha>, as evrex_search prints it"),
1901
+ signal: z.enum(["helpful", "not_helpful", "stale", "wrong", "superseded"]),
1902
+ note: z.string().optional().describe("One line on why \u2014 what you found instead"),
1903
+ item_id: z.string().optional().describe("One item within the record, e.g. rej:llm:2, con:llm:0, dec:llm:1"),
1904
+ superseded_by: z.string().optional().describe("For superseded: the handle of the record that replaced it")
1905
+ }
1906
+ },
1907
+ async ({ handle, signal, note, item_id, superseded_by }) => {
1908
+ const text = await evrexFeedback(handle, signal, note, item_id, superseded_by);
1909
+ return { content: [{ type: "text", text }] };
1910
+ }
1911
+ );
1403
1912
  server.registerTool(
1404
1913
  "evrex_bottle",
1405
1914
  {