evrex-mcp 0.8.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/capture.js CHANGED
@@ -180,7 +180,7 @@ function commitMeta(repoPath, sha) {
180
180
  const line = git(repoPath, [
181
181
  "show",
182
182
  "-s",
183
- `--format=%H${FIELD_SEP}%an${FIELD_SEP}%ae${FIELD_SEP}%aI`,
183
+ `--format=%H${FIELD_SEP}%an${FIELD_SEP}%ae${FIELD_SEP}%aI${FIELD_SEP}%P`,
184
184
  sha
185
185
  ]).trim();
186
186
  const parts = line.split(FIELD_SEP);
@@ -190,6 +190,7 @@ function commitMeta(repoPath, sha) {
190
190
  author: parts[1] ?? "",
191
191
  authorEmail: parts[2] ?? "",
192
192
  ts: parts[3] ?? (/* @__PURE__ */ new Date()).toISOString(),
193
+ parents: (parts[4] ?? "").split(" ").filter(Boolean),
193
194
  message
194
195
  };
195
196
  }
@@ -238,6 +239,10 @@ function trailerValue(trailers, key) {
238
239
  const k = key.toLowerCase();
239
240
  return trailers.find((t) => t.key.toLowerCase() === k)?.value ?? null;
240
241
  }
242
+ function originOf(trailers) {
243
+ const v = trailerValue(trailers, EVREX_ORIGIN_TRAILER_KEY);
244
+ return v?.trim().toLowerCase() === "human" ? "human" : null;
245
+ }
241
246
  function agentTrailersOf(trailers) {
242
247
  const out = [];
243
248
  for (const t of trailers) {
@@ -319,15 +324,17 @@ function parseGitLog(repoPath, repoId, known) {
319
324
  authorEmail: meta.authorEmail,
320
325
  ts: meta.ts,
321
326
  message: meta.message,
327
+ parents: meta.parents,
322
328
  branch: commitBranch(repoPath, sha),
323
329
  evrexSessionTrailer: trailerValue(trailers, EVREX_SESSION_TRAILER_KEY),
330
+ origin: originOf(trailers),
324
331
  agentTrailers: agentTrailersOf(trailers),
325
332
  statedInsights: statedInsightsOf(trailers),
326
333
  files: commitFiles(repoPath, sha)
327
334
  };
328
335
  });
329
336
  }
330
- var DIFF_CAP, FIELD_SEP, EVREX_SESSION_TRAILER_KEY;
337
+ var DIFF_CAP, FIELD_SEP, EVREX_SESSION_TRAILER_KEY, EVREX_ORIGIN_TRAILER_KEY;
331
338
  var init_git_history = __esm({
332
339
  "../../packages/ingest-core/src/git-history.ts"() {
333
340
  "use strict";
@@ -335,6 +342,7 @@ var init_git_history = __esm({
335
342
  DIFF_CAP = 2e4;
336
343
  FIELD_SEP = "";
337
344
  EVREX_SESSION_TRAILER_KEY = "Evrex-Session";
345
+ EVREX_ORIGIN_TRAILER_KEY = "Evrex-Origin";
338
346
  }
339
347
  });
340
348
 
@@ -811,6 +819,7 @@ function parseSessionFile(filePath, repoPath, repoId = deriveRepoId(repoPath), a
811
819
  let totalRedactions = 0;
812
820
  let branch = null;
813
821
  let agentId = null;
822
+ const seenIds = /* @__PURE__ */ new Set();
814
823
  for (const line of lines) {
815
824
  let record;
816
825
  try {
@@ -826,6 +835,8 @@ function parseSessionFile(filePath, repoPath, repoId = deriveRepoId(repoPath), a
826
835
  const id = record.uuid;
827
836
  const ts = record.timestamp;
828
837
  if (!id || !ts) continue;
838
+ if (seenIds.has(id)) continue;
839
+ seenIds.add(id);
829
840
  sessionId ??= record.sessionId ?? record.session_id ?? null;
830
841
  if (typeof record.gitBranch === "string" && record.gitBranch) branch = record.gitBranch;
831
842
  if (typeof record.agentId === "string" && record.agentId) agentId ??= record.agentId;
@@ -2928,6 +2939,7 @@ var src_exports = {};
2928
2939
  __export(src_exports, {
2929
2940
  CONVERSATION_KINDS: () => CONVERSATION_KINDS,
2930
2941
  EMPTY_USAGE: () => EMPTY_USAGE,
2942
+ EVREX_ORIGIN_TRAILER_KEY: () => EVREX_ORIGIN_TRAILER_KEY,
2931
2943
  EVREX_SESSION_TRAILER_KEY: () => EVREX_SESSION_TRAILER_KEY,
2932
2944
  JIRA_FIELDS: () => JIRA_FIELDS,
2933
2945
  JIRA_SEARCH_PATH: () => JIRA_SEARCH_PATH,
@@ -2986,6 +2998,7 @@ __export(src_exports, {
2986
2998
  measureLineSurvival: () => measureLineSurvival,
2987
2999
  normalizeRepoRemote: () => normalizeRepoRemote,
2988
3000
  opencodeDbPath: () => opencodeDbPath,
3001
+ originOf: () => originOf,
2989
3002
  parseAllCodexSessions: () => parseAllCodexSessions,
2990
3003
  parseAllCopilotSessions: () => parseAllCopilotSessions,
2991
3004
  parseAllCursorSessions: () => parseAllCursorSessions,
package/dist/import.js CHANGED
@@ -180,7 +180,7 @@ function commitMeta(repoPath, sha) {
180
180
  const line = git(repoPath, [
181
181
  "show",
182
182
  "-s",
183
- `--format=%H${FIELD_SEP}%an${FIELD_SEP}%ae${FIELD_SEP}%aI`,
183
+ `--format=%H${FIELD_SEP}%an${FIELD_SEP}%ae${FIELD_SEP}%aI${FIELD_SEP}%P`,
184
184
  sha
185
185
  ]).trim();
186
186
  const parts = line.split(FIELD_SEP);
@@ -190,6 +190,7 @@ function commitMeta(repoPath, sha) {
190
190
  author: parts[1] ?? "",
191
191
  authorEmail: parts[2] ?? "",
192
192
  ts: parts[3] ?? (/* @__PURE__ */ new Date()).toISOString(),
193
+ parents: (parts[4] ?? "").split(" ").filter(Boolean),
193
194
  message
194
195
  };
195
196
  }
@@ -238,6 +239,10 @@ function trailerValue(trailers, key) {
238
239
  const k = key.toLowerCase();
239
240
  return trailers.find((t) => t.key.toLowerCase() === k)?.value ?? null;
240
241
  }
242
+ function originOf(trailers) {
243
+ const v = trailerValue(trailers, EVREX_ORIGIN_TRAILER_KEY);
244
+ return v?.trim().toLowerCase() === "human" ? "human" : null;
245
+ }
241
246
  function agentTrailersOf(trailers) {
242
247
  const out = [];
243
248
  for (const t of trailers) {
@@ -319,15 +324,17 @@ function parseGitLog(repoPath, repoId, known) {
319
324
  authorEmail: meta.authorEmail,
320
325
  ts: meta.ts,
321
326
  message: meta.message,
327
+ parents: meta.parents,
322
328
  branch: commitBranch(repoPath, sha),
323
329
  evrexSessionTrailer: trailerValue(trailers, EVREX_SESSION_TRAILER_KEY),
330
+ origin: originOf(trailers),
324
331
  agentTrailers: agentTrailersOf(trailers),
325
332
  statedInsights: statedInsightsOf(trailers),
326
333
  files: commitFiles(repoPath, sha)
327
334
  };
328
335
  });
329
336
  }
330
- var DIFF_CAP, FIELD_SEP, EVREX_SESSION_TRAILER_KEY;
337
+ var DIFF_CAP, FIELD_SEP, EVREX_SESSION_TRAILER_KEY, EVREX_ORIGIN_TRAILER_KEY;
331
338
  var init_git_history = __esm({
332
339
  "../../packages/ingest-core/src/git-history.ts"() {
333
340
  "use strict";
@@ -335,6 +342,7 @@ var init_git_history = __esm({
335
342
  DIFF_CAP = 2e4;
336
343
  FIELD_SEP = "";
337
344
  EVREX_SESSION_TRAILER_KEY = "Evrex-Session";
345
+ EVREX_ORIGIN_TRAILER_KEY = "Evrex-Origin";
338
346
  }
339
347
  });
340
348
 
@@ -811,6 +819,7 @@ function parseSessionFile(filePath, repoPath, repoId = deriveRepoId(repoPath), a
811
819
  let totalRedactions = 0;
812
820
  let branch = null;
813
821
  let agentId = null;
822
+ const seenIds = /* @__PURE__ */ new Set();
814
823
  for (const line of lines) {
815
824
  let record;
816
825
  try {
@@ -826,6 +835,8 @@ function parseSessionFile(filePath, repoPath, repoId = deriveRepoId(repoPath), a
826
835
  const id = record.uuid;
827
836
  const ts = record.timestamp;
828
837
  if (!id || !ts) continue;
838
+ if (seenIds.has(id)) continue;
839
+ seenIds.add(id);
829
840
  sessionId ??= record.sessionId ?? record.session_id ?? null;
830
841
  if (typeof record.gitBranch === "string" && record.gitBranch) branch = record.gitBranch;
831
842
  if (typeof record.agentId === "string" && record.agentId) agentId ??= record.agentId;
@@ -2928,6 +2939,7 @@ var src_exports = {};
2928
2939
  __export(src_exports, {
2929
2940
  CONVERSATION_KINDS: () => CONVERSATION_KINDS,
2930
2941
  EMPTY_USAGE: () => EMPTY_USAGE,
2942
+ EVREX_ORIGIN_TRAILER_KEY: () => EVREX_ORIGIN_TRAILER_KEY,
2931
2943
  EVREX_SESSION_TRAILER_KEY: () => EVREX_SESSION_TRAILER_KEY,
2932
2944
  JIRA_FIELDS: () => JIRA_FIELDS,
2933
2945
  JIRA_SEARCH_PATH: () => JIRA_SEARCH_PATH,
@@ -2986,6 +2998,7 @@ __export(src_exports, {
2986
2998
  measureLineSurvival: () => measureLineSurvival,
2987
2999
  normalizeRepoRemote: () => normalizeRepoRemote,
2988
3000
  opencodeDbPath: () => opencodeDbPath,
3001
+ originOf: () => originOf,
2989
3002
  parseAllCodexSessions: () => parseAllCodexSessions,
2990
3003
  parseAllCopilotSessions: () => parseAllCopilotSessions,
2991
3004
  parseAllCursorSessions: () => parseAllCursorSessions,
@@ -3544,7 +3557,44 @@ async function durability(target, argv) {
3544
3557
  const surviving = measurements.reduce((n, m) => n + m.survivingLines, 0);
3545
3558
  console.error(` ${body.updated} commits recorded \xB7 ${surviving}/${added} added lines still at HEAD (${added ? Math.round(100 * surviving / added) : 0}%)`);
3546
3559
  }
3560
+ async function parents(target) {
3561
+ const { deriveRepoId: deriveRepoId2 } = await Promise.resolve().then(() => (init_src(), src_exports));
3562
+ const { execFileSync: execFileSync5 } = await import("node:child_process");
3563
+ const { evrexApi: evrexApi2 } = await Promise.resolve().then(() => (init_client(), client_exports));
3564
+ const token = process.env.EVREX_TOKEN ?? await new (await Promise.resolve().then(() => (init_credential_store(), credential_store_exports))).CredentialStore().retrieve().catch(() => null);
3565
+ if (!token) {
3566
+ console.error("evrex: this machine is not enrolled.\n Run `npx -y evrex-mcp enrol` first, or set EVREX_TOKEN.");
3567
+ process.exit(1);
3568
+ }
3569
+ const repoId = deriveRepoId2(target);
3570
+ const rows = execFileSync5("git", ["rev-list", "--parents", "--all"], { cwd: target, maxBuffer: 64 * 1024 * 1024 }).toString("utf-8").split("\n").filter(Boolean).map((line) => {
3571
+ const [sha, ...parents2] = line.split(" ");
3572
+ return { sha, parents: parents2 };
3573
+ });
3574
+ console.error(`Reporting parents for ${rows.length} commits in ${target} (${rows.filter((r) => r.parents.length > 1).length} merges)`);
3575
+ let updated = 0;
3576
+ let inherited = 0;
3577
+ for (let i = 0; i < rows.length; i += 500) {
3578
+ const res = await fetch(`${evrexApi2.baseUrl}/ingest/commits/parents`, {
3579
+ method: "POST",
3580
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
3581
+ body: JSON.stringify({ repoId, commits: rows.slice(i, i + 500) })
3582
+ });
3583
+ if (!res.ok) {
3584
+ console.error(` /ingest/commits/parents -> ${res.status}`);
3585
+ process.exit(1);
3586
+ }
3587
+ const body = await res.json();
3588
+ updated += body.updated;
3589
+ inherited = body.inherited;
3590
+ }
3591
+ console.error(` ${updated} commits updated \xB7 merges now inherit ${inherited} link(s)`);
3592
+ }
3547
3593
  async function main() {
3594
+ if (process.argv[2] === "parents") {
3595
+ await parents(resolve(process.argv[3] && !process.argv[3].startsWith("--") ? process.argv[3] : process.cwd()));
3596
+ return;
3597
+ }
3548
3598
  if (process.argv[2] === "durability") {
3549
3599
  await durability(resolve(process.argv[3] && !process.argv[3].startsWith("--") ? process.argv[3] : process.cwd()), process.argv.slice(3));
3550
3600
  return;
package/dist/index.js CHANGED
@@ -646,6 +646,29 @@ ${request2.user}`;
646
646
  // ../../packages/llm-core/src/provider-clients.ts
647
647
  import Anthropic3 from "@anthropic-ai/sdk";
648
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
+ }
649
672
  var realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
650
673
  var MAX_RETRIES = 4;
651
674
  var RETRYABLE_STATUS = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
@@ -653,12 +676,12 @@ function isExhaustedStatus(status, retriesSpent) {
653
676
  if (status === 401 || status === 403) return true;
654
677
  return status === 429 && retriesSpent;
655
678
  }
656
- async function waitBeforeRetry(response, attempt, sleep) {
679
+ async function waitBeforeRetry(response, attempt, sleep2) {
657
680
  const header = response.headers.get("retry-after") ?? response.headers.get("x-ratelimit-reset-tokens");
658
681
  const seconds = header ? parseDuration(header) : null;
659
682
  const backoff = Math.min(8 * 2 ** attempt, 60);
660
683
  const wait = Math.min(seconds ?? backoff, MAX_RETRY_WAIT_SECONDS);
661
- await sleep(wait * 1e3);
684
+ await sleep2(wait * 1e3);
662
685
  }
663
686
  var MAX_RETRY_WAIT_SECONDS = 90;
664
687
  function parseDuration(value) {
@@ -678,7 +701,7 @@ function parseJsonBody(text) {
678
701
  return null;
679
702
  }
680
703
  }
681
- var AnthropicClient = class {
704
+ var AnthropicClient = class _AnthropicClient {
682
705
  constructor(key, model, logger) {
683
706
  this.model = model;
684
707
  this.logger = logger;
@@ -687,37 +710,47 @@ var AnthropicClient = class {
687
710
  provider = "anthropic";
688
711
  exhausted = false;
689
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
+ }
690
747
  async completeJson(request2) {
691
748
  try {
692
- const message = await this.sdk.messages.create({
693
- model: request2.model ?? this.model,
694
- max_tokens: request2.maxTokens ?? DEFAULT_MAX_TOKENS,
695
- // The breakpoint sits at the end of the system prompt, which is the
696
- // only part of these requests that repeats. Rendering order is
697
- // tools -> system -> messages, so a marker here caches everything
698
- // ahead of the transcript.
699
- //
700
- // Deliberately NOT on the user block. That block is a different
701
- // transcript chunk on every call, so a breakpoint there would write a
702
- // fresh cache entry per request and read none of them back — paying
703
- // the write premium for nothing, which is worse than not caching.
704
- system: [
705
- {
706
- type: "text",
707
- text: request2.system,
708
- cache_control: { type: "ephemeral" }
709
- }
710
- ],
711
- messages: [{ role: "user", content: request2.user }],
712
- ...request2.schema ? {
713
- output_config: {
714
- format: { type: "json_schema", schema: request2.schema }
715
- }
716
- } : {}
717
- });
749
+ const message = await this.sdk.messages.create(
750
+ this.messageParams(request2)
751
+ );
718
752
  this.reportCacheUsage(message.usage);
719
- const text = message.content.filter((b) => b.type === "text").map((b) => b.text).join("");
720
- return parseJsonBody(text);
753
+ return parseJsonBody(_AnthropicClient.messageText(message));
721
754
  } catch (err) {
722
755
  const status = err?.status;
723
756
  if (typeof status === "number" && isExhaustedStatus(status, true)) {
@@ -727,6 +760,58 @@ var AnthropicClient = class {
727
760
  return null;
728
761
  }
729
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
+ }
730
815
  /**
731
816
  * Says out loud whether the cache was actually used.
732
817
  *
@@ -753,12 +838,12 @@ var AnthropicClient = class {
753
838
  );
754
839
  }
755
840
  };
756
- var GoogleClient = class {
757
- constructor(key, model, logger, sleep = realSleep) {
841
+ var GoogleClient = class _GoogleClient {
842
+ constructor(key, model, logger, sleep2 = realSleep) {
758
843
  this.key = key;
759
844
  this.model = model;
760
845
  this.logger = logger;
761
- this.sleep = sleep;
846
+ this.sleep = sleep2;
762
847
  }
763
848
  provider = "google";
764
849
  exhausted = false;
@@ -801,56 +886,129 @@ ${JSON.stringify(request2.schema)}` : "";
801
886
  return null;
802
887
  }
803
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
+ }
804
991
  };
805
992
  var OpenAiCompatibleClient = class {
806
- constructor(provider, baseUrl, key, model, logger, sleep = realSleep) {
993
+ constructor(provider, baseUrl, key, model, logger, sleep2 = realSleep) {
807
994
  this.provider = provider;
808
995
  this.baseUrl = baseUrl;
809
996
  this.key = key;
810
997
  this.model = model;
811
998
  this.logger = logger;
812
- this.sleep = sleep;
999
+ this.sleep = sleep2;
1000
+ if (this.provider === "openai") {
1001
+ this.completeJsonBatch = (items) => this.runBatch(items);
1002
+ }
813
1003
  }
814
1004
  exhausted = false;
1005
+ completeJsonBatch;
815
1006
  async completeJson(request2, attempt = 0) {
816
- const strict = this.provider === "openai";
817
- const shape = request2.schema && !strict ? `
818
-
819
- Respond with JSON matching exactly this schema:
820
- ${JSON.stringify(request2.schema)}` : "";
821
1007
  try {
822
1008
  const res = await fetch(`${this.baseUrl}/chat/completions`, {
823
1009
  method: "POST",
824
- headers: {
825
- "Content-Type": "application/json",
826
- // A local server does not want one, and some reject an empty bearer
827
- // token outright.
828
- ...this.key ? { Authorization: `Bearer ${this.key}` } : {}
829
- },
830
- body: JSON.stringify({
831
- model: request2.model ?? this.model,
832
- messages: [
833
- { role: "system", content: request2.system + shape },
834
- { role: "user", content: request2.user }
835
- ],
836
- ...request2.schema && strict ? {
837
- response_format: {
838
- type: "json_schema",
839
- json_schema: {
840
- name: "evrex_result",
841
- strict: true,
842
- schema: request2.schema
843
- }
844
- }
845
- } : { response_format: { type: "json_object" } },
846
- // Extraction is a reading task with a right answer, not a writing
847
- // task, and a default sampling temperature makes it a different
848
- // answer each run: one model scored 53% and then 20% on the same ten
849
- // sessions before this was pinned. Indexing twice must not produce
850
- // two different histories.
851
- temperature: 0,
852
- stream: false
853
- })
1010
+ headers: this.authHeaders(),
1011
+ body: JSON.stringify(this.chatBody(request2))
854
1012
  });
855
1013
  if (RETRYABLE_STATUS.has(res.status) && attempt < MAX_RETRIES) {
856
1014
  await waitBeforeRetry(res, attempt, this.sleep);
@@ -871,6 +1029,147 @@ ${JSON.stringify(request2.schema)}` : "";
871
1029
  return null;
872
1030
  }
873
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
+ }
874
1173
  };
875
1174
  function describeError(err) {
876
1175
  return err instanceof Error ? err.message : String(err);
@@ -882,9 +1181,9 @@ function createModelClient(key, options = {}) {
882
1181
  const model = options.model ?? DEFAULT_MODELS[provider];
883
1182
  const trimmed = key.trim();
884
1183
  if (provider === "anthropic") return new AnthropicClient(trimmed, model, logger);
885
- const sleep = options.sleep ?? realSleep;
1184
+ const sleep2 = options.sleep ?? realSleep;
886
1185
  if (provider === "google")
887
- return new GoogleClient(trimmed, model, logger, sleep);
1186
+ return new GoogleClient(trimmed, model, logger, sleep2);
888
1187
  const cli = CLI_SPECS[provider];
889
1188
  if (cli) return new CliClient(cli, model, logger);
890
1189
  if (provider === "local") {
@@ -894,7 +1193,7 @@ function createModelClient(key, options = {}) {
894
1193
  null,
895
1194
  model,
896
1195
  logger,
897
- sleep
1196
+ sleep2
898
1197
  );
899
1198
  }
900
1199
  const baseUrl = options.baseUrl ?? OPENAI_COMPATIBLE_BASE_URLS[provider];
@@ -905,7 +1204,7 @@ function createModelClient(key, options = {}) {
905
1204
  trimmed,
906
1205
  model,
907
1206
  logger,
908
- sleep
1207
+ sleep2
909
1208
  );
910
1209
  }
911
1210
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "evrex-mcp",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
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",
@@ -36,27 +36,28 @@
36
36
  "engines": {
37
37
  "node": ">=20"
38
38
  },
39
+ "scripts": {
40
+ "build": "tsc -p tsconfig.json",
41
+ "bundle": "node scripts/bundle.mjs",
42
+ "dev": "tsc -p tsconfig.json --watch",
43
+ "lint": "eslint .",
44
+ "check-types": "tsc --noEmit",
45
+ "test": "tsc -p tsconfig.json && node --test dist/*.test.js",
46
+ "prepack": "pnpm run bundle"
47
+ },
39
48
  "dependencies": {
40
49
  "@anthropic-ai/sdk": "^0.72.0",
41
50
  "@modelcontextprotocol/sdk": "^1.20.1",
42
51
  "zod": "^3.24.1"
43
52
  },
44
53
  "devDependencies": {
54
+ "@repo/eslint-config": "workspace:*",
55
+ "@repo/ingest-core": "workspace:*",
56
+ "@repo/llm-core": "workspace:*",
57
+ "@repo/typescript-config": "workspace:*",
45
58
  "@types/node": "^24.0.0",
46
59
  "esbuild": "^0.25.12",
47
60
  "eslint": "^9.39.1",
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"
61
+ "typescript": "^5.9.2"
61
62
  }
62
- }
63
+ }