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/README.md +11 -1
- package/dist/account.js +210 -0
- package/dist/capture.js +2604 -166
- package/dist/continuity.js +83 -10
- package/dist/hook.js +106 -11
- package/dist/import.js +2336 -350
- package/dist/index.js +597 -88
- package/dist/pretool.js +67 -16
- package/dist/tickets.js +24 -1
- package/package.json +4 -2
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,
|
|
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
|
|
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
|
-
|
|
685
|
-
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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
|
|
1184
|
+
const sleep2 = options.sleep ?? realSleep;
|
|
877
1185
|
if (provider === "google")
|
|
878
|
-
return new GoogleClient(trimmed, model, logger,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
1510
|
+
"CONSTRAINTS:\n" + [...constraints.map((c) => `- [${c.source}] ${c.text}`), ...statedConstraints.map(statedLine)].join("\n")
|
|
1177
1511
|
);
|
|
1178
1512
|
}
|
|
1179
|
-
if (
|
|
1180
|
-
parts.push(
|
|
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
|
-
|
|
1183
|
-
|
|
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 =
|
|
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.
|
|
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
|
{
|