@evomap/evolver 1.91.1 → 1.92.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/index.js +150 -71
  2. package/package.json +2 -1
  3. package/src/canonicalIdentityLock.js +455 -0
  4. package/src/evolve/guards.js +1 -1
  5. package/src/evolve/pipeline/collect.js +1 -1
  6. package/src/evolve/pipeline/dispatch.js +1 -1
  7. package/src/evolve/pipeline/enrich.js +1 -1
  8. package/src/evolve/pipeline/hub.js +1 -1
  9. package/src/evolve/pipeline/select.js +1 -1
  10. package/src/evolve/pipeline/signals.js +1 -1
  11. package/src/evolve/utils.js +1 -1
  12. package/src/evolve.js +1 -1
  13. package/src/gep/a2aProtocol.js +1 -1
  14. package/src/gep/antiAbuseTelemetry.js +1 -1
  15. package/src/gep/assetStore.js +56 -11
  16. package/src/gep/autoDistillConv.js +1 -1
  17. package/src/gep/autoDistillLlm.js +1 -1
  18. package/src/gep/candidateEval.js +1 -1
  19. package/src/gep/candidates.js +1 -1
  20. package/src/gep/contentHash.js +1 -1
  21. package/src/gep/conversationDistiller.js +1 -1
  22. package/src/gep/conversationSniffer.js +1 -1
  23. package/src/gep/crypto.js +1 -1
  24. package/src/gep/curriculum.js +1 -1
  25. package/src/gep/deviceId.js +1 -1
  26. package/src/gep/envFingerprint.js +1 -1
  27. package/src/gep/epigenetics.js +1 -1
  28. package/src/gep/execBridge.js +1 -1
  29. package/src/gep/explore.js +1 -1
  30. package/src/gep/hash.js +1 -1
  31. package/src/gep/hubFetch.js +1 -1
  32. package/src/gep/hubReview.js +1 -1
  33. package/src/gep/hubSearch.js +1 -1
  34. package/src/gep/hubVerify.js +1 -1
  35. package/src/gep/learningSignals.js +1 -1
  36. package/src/gep/memoryGraph.js +1 -1
  37. package/src/gep/memoryGraphAdapter.js +1 -1
  38. package/src/gep/mutation.js +1 -1
  39. package/src/gep/narrativeMemory.js +1 -1
  40. package/src/gep/openPRRegistry.js +1 -1
  41. package/src/gep/personality.js +1 -1
  42. package/src/gep/policyCheck.js +1 -1
  43. package/src/gep/prompt.js +1 -1
  44. package/src/gep/recallInject.js +1 -1
  45. package/src/gep/recallVerifier.js +1 -1
  46. package/src/gep/reflection.js +1 -1
  47. package/src/gep/savingsCore.js +1 -1
  48. package/src/gep/schemas/gene.js +2 -0
  49. package/src/gep/selector.js +1 -1
  50. package/src/gep/skillDistiller.js +1 -1
  51. package/src/gep/solidify.js +1 -1
  52. package/src/gep/strategy.js +1 -1
  53. package/src/gep/syncAsset.js +1 -0
  54. package/src/gep/tokenSavings.js +1 -1
  55. package/src/gep/trajectoryExport.js +1 -1
  56. package/src/gep/workspaceKeychain.js +1 -1
  57. package/src/proxy/extensions/traceControl.js +1 -1
  58. package/src/proxy/index.js +136 -8
  59. package/src/proxy/inject.js +1 -1
  60. package/src/proxy/lifecycle/manager.js +279 -18
  61. package/src/proxy/mailbox/state.js +60 -29
  62. package/src/proxy/trace/extractor.js +1 -1
  63. package/src/proxy/trace/usage.js +1 -1
  64. package/src/webui/client/clientJs/bootstrap.js +6 -0
  65. package/src/webui/client/clientJs/github.js +225 -0
  66. package/src/webui/client/clientJs/i18n.js +14 -0
  67. package/src/webui/client/clientJs/index.js +3 -0
  68. package/src/webui/client/clientJs/pipelines.js +3 -1
  69. package/src/webui/client/indexHtml.js +10 -0
  70. package/src/webui/client/stylesCss.js +61 -0
  71. package/src/webui/observer/github.js +340 -0
  72. package/src/webui/observer/index.js +4 -0
  73. package/src/webui/server/routes.js +11 -0
@@ -0,0 +1,340 @@
1
+ 'use strict';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // observer/github — read-only GitHub PR status for the Web UI hovercard.
5
+ //
6
+ // Mirrors the Claude Code "hover a #123 → floating PR status card" behaviour:
7
+ // the client linkifies PR references and, on hover, asks this observer for the
8
+ // live status of one PR (state, title, author, +additions/-deletions, changed
9
+ // files, timestamps). It also feeds a dedicated "Pull Requests" panel with the
10
+ // repo's open PRs.
11
+ //
12
+ // Data source precedence (per the approved plan):
13
+ // 1. `gh pr view N --json ...` — argv form, NO shell (mirrors selfPR.runGh),
14
+ // reuses the operator's existing `gh` auth.
15
+ // 2. GitHub REST `/repos/{slug}/pulls/{n}` — fallback when `gh` is missing,
16
+ // unauthenticated, times out, or errors. Token optional (public repos
17
+ // read without one), resolved like issueReporter.getGithubToken().
18
+ //
19
+ // The module is graceful by construction: EVERY failure path returns
20
+ // `{ number, available: false, reason }` rather than throwing, so a flaky
21
+ // network or a non-GitHub checkout can never break the dashboard. A short
22
+ // negative cache stops hover-jitter from amplifying failed lookups into a
23
+ // request storm. EVOLVER_WEBUI_GITHUB=0 short-circuits the whole feature.
24
+ // ---------------------------------------------------------------------------
25
+
26
+ // Note: do not destructure execFileSync / fetch at module load — tests stub
27
+ // them at call time via the live references (child_process.execFileSync and
28
+ // global.fetch), matching the openPRRegistry / issueReporter test style.
29
+ const { envInt } = require('../../config');
30
+ const { getRepoRoot } = require('../../gep/paths');
31
+
32
+ const MAX_EXEC_BUFFER = 10 * 1024 * 1024;
33
+ const GH_TIMEOUT_MS = 5000;
34
+ const API_TIMEOUT_MS = 10000;
35
+ const NEG_CACHE_TTL_MS = 10000; // failed lookups: cache briefly to damp hover jitter
36
+ const MAX_CACHE_ENTRIES = 200; // bound memory; LRU-evict oldest beyond this
37
+
38
+ // gh's --json field list. Kept identical to the REST fields we normalise from
39
+ // so both sources produce the same shape.
40
+ const GH_PR_FIELDS = 'number,title,state,isDraft,author,additions,deletions,changedFiles,createdAt,updatedAt,mergedAt,closedAt,url';
41
+
42
+ const _prCache = new Map(); // number -> { data, at }
43
+ const _inflight = new Map(); // number -> Promise<data>
44
+ let _ghMissingWarned = false;
45
+
46
+ function _now() { return Date.now(); }
47
+
48
+ function _isFeatureEnabled() {
49
+ return String(process.env.EVOLVER_WEBUI_GITHUB || '1') !== '0';
50
+ }
51
+
52
+ function _getTtlMs() {
53
+ return envInt('EVOLVER_WEBUI_GITHUB_TTL_MS', 60000);
54
+ }
55
+
56
+ function _getGithubToken() {
57
+ return process.env.GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_PAT || '';
58
+ }
59
+
60
+ // A PR number is untrusted route input. Accept only a positive *safe* integer;
61
+ // never let anything else reach `gh` or the REST URL. The safe-integer bound
62
+ // applies to both branches so a huge numeric input (e.g. Number('9'.repeat(23))
63
+ // === 1e23, whose String() is the non-numeric '1e+23') is rejected cleanly
64
+ // rather than handed to `gh pr view 1e+23`.
65
+ function _normalizeNumber(input) {
66
+ if (typeof input === 'number') {
67
+ return Number.isSafeInteger(input) && input > 0 ? input : null;
68
+ }
69
+ const s = String(input == null ? '' : input).trim();
70
+ if (!/^\d+$/.test(s)) return null;
71
+ const n = Number(s);
72
+ return Number.isSafeInteger(n) && n > 0 ? n : null;
73
+ }
74
+
75
+ // --- Repo slug resolution ---------------------------------------------------
76
+ // Precedence: EVOLVER_GITHUB_REPO → `git remote get-url origin` → SELF_PR_REPO.
77
+ let _slugCache; // undefined = unresolved; string|null once resolved
78
+
79
+ function _parseSlugFromRemote(url) {
80
+ const m = String(url || '').trim().match(/github\.com[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/i);
81
+ return m ? m[1] + '/' + m[2] : null;
82
+ }
83
+
84
+ function _resolveRepoSlug() {
85
+ if (_slugCache !== undefined) return _slugCache;
86
+ const envSlug = String(process.env.EVOLVER_GITHUB_REPO || '').trim();
87
+ if (envSlug) { _slugCache = envSlug; return _slugCache; }
88
+ try {
89
+ const out = require('child_process').execFileSync('git', ['remote', 'get-url', 'origin'], {
90
+ cwd: getRepoRoot(),
91
+ encoding: 'utf8',
92
+ timeout: GH_TIMEOUT_MS,
93
+ stdio: ['ignore', 'pipe', 'ignore'],
94
+ maxBuffer: MAX_EXEC_BUFFER,
95
+ });
96
+ const parsed = _parseSlugFromRemote(out);
97
+ if (parsed) { _slugCache = parsed; return _slugCache; }
98
+ } catch (_) { /* fall through to config default */ }
99
+ try {
100
+ _slugCache = require('../../config').SELF_PR_REPO || null;
101
+ } catch (_) {
102
+ _slugCache = null;
103
+ }
104
+ return _slugCache;
105
+ }
106
+
107
+ // --- Normalisation ----------------------------------------------------------
108
+ // gh: state ∈ {OPEN, CLOSED, MERGED} + isDraft. REST: state ∈ {open, closed} +
109
+ // merged_at + draft. Collapse both to merged|open|closed|draft.
110
+ function _normalizeState(rawState, isDraft, mergedAt) {
111
+ if (mergedAt) return 'merged';
112
+ const s = String(rawState || '').toLowerCase();
113
+ if (s === 'merged') return 'merged';
114
+ if (s === 'closed') return 'closed';
115
+ if (isDraft) return 'draft';
116
+ return s === 'open' ? 'open' : (s || 'open');
117
+ }
118
+
119
+ function _normalizeGh(raw) {
120
+ const author = raw.author && typeof raw.author === 'object'
121
+ ? String(raw.author.login || raw.author.name || '')
122
+ : '';
123
+ return {
124
+ number: raw.number,
125
+ title: String(raw.title || ''),
126
+ state: _normalizeState(raw.state, raw.isDraft, raw.mergedAt),
127
+ author,
128
+ additions: Number(raw.additions) || 0,
129
+ deletions: Number(raw.deletions) || 0,
130
+ changedFiles: Number(raw.changedFiles) || 0,
131
+ createdAt: raw.createdAt || null,
132
+ updatedAt: raw.updatedAt || null,
133
+ mergedAt: raw.mergedAt || null,
134
+ closedAt: raw.closedAt || null,
135
+ url: String(raw.url || ''),
136
+ source: 'gh',
137
+ available: true,
138
+ };
139
+ }
140
+
141
+ function _normalizeApi(raw) {
142
+ const author = raw.user && typeof raw.user === 'object' ? String(raw.user.login || '') : '';
143
+ return {
144
+ number: raw.number,
145
+ title: String(raw.title || ''),
146
+ state: _normalizeState(raw.state, raw.draft, raw.merged_at),
147
+ author,
148
+ additions: Number(raw.additions) || 0,
149
+ deletions: Number(raw.deletions) || 0,
150
+ changedFiles: Number(raw.changed_files) || 0,
151
+ createdAt: raw.created_at || null,
152
+ updatedAt: raw.updated_at || null,
153
+ mergedAt: raw.merged_at || null,
154
+ closedAt: raw.closed_at || null,
155
+ url: String(raw.html_url || ''),
156
+ source: 'api',
157
+ available: true,
158
+ };
159
+ }
160
+
161
+ // --- Fetch paths ------------------------------------------------------------
162
+ // Returns a normalized PR object, or null if this source could not answer
163
+ // (so the caller can try the next source).
164
+ function _fetchViaGh(n) {
165
+ let raw;
166
+ try {
167
+ raw = require('child_process').execFileSync(
168
+ 'gh',
169
+ ['pr', 'view', String(n), '--json', GH_PR_FIELDS],
170
+ {
171
+ cwd: getRepoRoot(),
172
+ encoding: 'utf8',
173
+ timeout: GH_TIMEOUT_MS,
174
+ stdio: ['ignore', 'pipe', 'pipe'],
175
+ maxBuffer: MAX_EXEC_BUFFER,
176
+ },
177
+ );
178
+ } catch (e) {
179
+ const msg = e && e.message ? String(e.message) : '';
180
+ // Distinguish "gh binary is absent" (spawn failure) from "gh ran but
181
+ // couldn't answer" (unknown/private PR → non-zero exit). Only the former
182
+ // should latch the fall-back-to-REST warning. Match the spawn-failure
183
+ // signals (`e.code === 'ENOENT'`, or "command not found" from a shell
184
+ // wrapper) — NOT a bare "not found", which also appears in gh's own
185
+ // "no pull requests found" message for a real but missing PR.
186
+ const ghMissing = (e && e.code === 'ENOENT') || /\bENOENT\b|command not found/i.test(msg);
187
+ if (ghMissing && !_ghMissingWarned) {
188
+ _ghMissingWarned = true;
189
+ console.warn('[WebUI/GitHub] gh CLI not available — falling back to REST API. Install gh or set a GITHUB_TOKEN.');
190
+ }
191
+ // Non-zero exit (unknown PR, auth) also lands here: signal "gh can't answer"
192
+ // so the caller falls through to the REST path.
193
+ return null;
194
+ }
195
+ let parsed;
196
+ try {
197
+ parsed = JSON.parse(raw || '{}');
198
+ } catch (_) {
199
+ return null;
200
+ }
201
+ if (!parsed || typeof parsed !== 'object' || parsed.number == null) return null;
202
+ return _normalizeGh(parsed);
203
+ }
204
+
205
+ async function _fetchViaApi(n) {
206
+ const slug = _resolveRepoSlug();
207
+ if (!slug) return { number: n, available: false, reason: 'no_repo_slug' };
208
+ const token = _getGithubToken();
209
+ const headers = {
210
+ 'Accept': 'application/vnd.github+json',
211
+ 'X-GitHub-Api-Version': '2022-11-28',
212
+ 'User-Agent': 'evolver-webui',
213
+ };
214
+ if (token) headers['Authorization'] = 'Bearer ' + token;
215
+ const url = 'https://api.github.com/repos/' + slug + '/pulls/' + n;
216
+ let res;
217
+ try {
218
+ res = await fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(API_TIMEOUT_MS) });
219
+ } catch (_) {
220
+ return { number: n, available: false, reason: 'network_error' };
221
+ }
222
+ if (res.status === 404) return { number: n, available: false, reason: 'not_found' };
223
+ if (res.status === 403 || res.status === 429) return { number: n, available: false, reason: 'rate_limited' };
224
+ if (!res.ok) return { number: n, available: false, reason: 'http_' + res.status };
225
+ let data;
226
+ try {
227
+ data = await res.json();
228
+ } catch (_) {
229
+ return { number: n, available: false, reason: 'bad_json' };
230
+ }
231
+ if (!data || data.number == null) return { number: n, available: false, reason: 'bad_json' };
232
+ return _normalizeApi(data);
233
+ }
234
+
235
+ // --- Cache helpers ----------------------------------------------------------
236
+ function _cacheGet(n) {
237
+ const hit = _prCache.get(n);
238
+ if (!hit) return null;
239
+ const ttl = hit.data && hit.data.available ? _getTtlMs() : NEG_CACHE_TTL_MS;
240
+ if (_now() - hit.at >= ttl) {
241
+ _prCache.delete(n);
242
+ return null;
243
+ }
244
+ // LRU touch: re-insert so recently-read entries survive eviction.
245
+ _prCache.delete(n);
246
+ _prCache.set(n, hit);
247
+ return hit.data;
248
+ }
249
+
250
+ function _cacheSet(n, data) {
251
+ _prCache.set(n, { data, at: _now() });
252
+ while (_prCache.size > MAX_CACHE_ENTRIES) {
253
+ const oldest = _prCache.keys().next().value;
254
+ _prCache.delete(oldest);
255
+ }
256
+ }
257
+
258
+ // --- Public API -------------------------------------------------------------
259
+
260
+ // Get one PR's normalized status. Never throws. Shape on success:
261
+ // { number, title, state, author, additions, deletions, changedFiles,
262
+ // createdAt, updatedAt, mergedAt, closedAt, url, source, available:true }
263
+ // On any failure (or feature disabled): { number, available:false, reason }.
264
+ async function getPrStatus(input) {
265
+ const n = _normalizeNumber(input);
266
+ if (n == null) return { number: null, available: false, reason: 'invalid_number' };
267
+ if (!_isFeatureEnabled()) return { number: n, available: false, reason: 'feature_disabled' };
268
+
269
+ const cached = _cacheGet(n);
270
+ if (cached) return cached;
271
+
272
+ // Single-flight: concurrent hovers on the same #N share one lookup.
273
+ const pending = _inflight.get(n);
274
+ if (pending) return pending;
275
+
276
+ const promise = (async () => {
277
+ let data = _fetchViaGh(n);
278
+ if (!data) data = await _fetchViaApi(n);
279
+ if (!data) data = { number: n, available: false, reason: 'unavailable' };
280
+ _cacheSet(n, data);
281
+ return data;
282
+ })().finally(() => { _inflight.delete(n); });
283
+
284
+ _inflight.set(n, promise);
285
+ return promise;
286
+ }
287
+
288
+ // Open PRs for the dedicated panel. Reuses the existing open-PR registry so we
289
+ // don't spawn a second gh path. Returns [] on any failure.
290
+ //
291
+ // Note: openPRRegistry.getOpenPRs() is independently gated by
292
+ // EVOLVE_OPEN_PR_DEDUP — if an operator has the hovercard on
293
+ // (EVOLVER_WEBUI_GITHUB=1) but that dedup flag off (EVOLVE_OPEN_PR_DEDUP=0),
294
+ // this returns [] and the panel shows its "no open PRs (or gh unavailable)"
295
+ // empty state. That's intentional: we don't override another module's feature
296
+ // flag from here. Hovering any #N reference still fetches live status via the
297
+ // independent getPrStatus() path, so the core capability is unaffected.
298
+ function getOpenPrs() {
299
+ if (!_isFeatureEnabled()) return [];
300
+ try {
301
+ const { getOpenPRs } = require('../../gep/openPRRegistry');
302
+ const prs = getOpenPRs() || [];
303
+ return prs.map((pr) => ({
304
+ number: pr.number,
305
+ title: String(pr.title || ''),
306
+ headRefName: String(pr.headRefName || ''),
307
+ fileCount: Array.isArray(pr.files) ? pr.files.length : 0,
308
+ }));
309
+ } catch (_) {
310
+ return [];
311
+ }
312
+ }
313
+
314
+ // Repo slug + PR URL base, so the client can build hrefs and linkify #N.
315
+ function getRepoInfo() {
316
+ const slug = _isFeatureEnabled() ? _resolveRepoSlug() : null;
317
+ return {
318
+ slug: slug || null,
319
+ prUrlBase: slug ? 'https://github.com/' + slug + '/pull' : null,
320
+ available: !!slug,
321
+ };
322
+ }
323
+
324
+ // Internal: reset caches for tests.
325
+ function _resetForTesting() {
326
+ _prCache.clear();
327
+ _inflight.clear();
328
+ _slugCache = undefined;
329
+ _ghMissingWarned = false;
330
+ }
331
+
332
+ module.exports = {
333
+ getPrStatus,
334
+ getOpenPrs,
335
+ getRepoInfo,
336
+ _resetForTesting,
337
+ _normalizeNumber,
338
+ _parseSlugFromRemote,
339
+ _normalizeState,
340
+ };
@@ -7,6 +7,7 @@ const { getAssetOverview, listGenes, listCapsules, listEvents, listCandidates, l
7
7
  const { getInteractions } = require('./interactions');
8
8
  const { getPersonality, getMemoryGraph } = require('./personality');
9
9
  const { listSkills } = require('./skills');
10
+ const { getPrStatus, getOpenPrs, getRepoInfo } = require('./github');
10
11
  const { getObserverPaths } = require('./paths');
11
12
  const { tailText } = require('./jsonl');
12
13
  const { redactText } = require('./redact');
@@ -34,4 +35,7 @@ module.exports = {
34
35
  getMemoryGraph,
35
36
  listSkills,
36
37
  getEvolverLog,
38
+ getPrStatus,
39
+ getOpenPrs,
40
+ getRepoInfo,
37
41
  };
@@ -24,6 +24,17 @@ function buildWebUiRoutes() {
24
24
  'GET /webui/memory-graph': async ({ query }) => ({ body: observer.getMemoryGraph(query) }),
25
25
  'GET /webui/skills': async () => ({ body: observer.listSkills() }),
26
26
  'GET /webui/logs/evolver': async ({ query }) => ({ body: observer.getEvolverLog(query) }),
27
+ 'GET /webui/github/repo': async () => ({ body: observer.getRepoInfo() }),
28
+ 'GET /webui/github/prs': async () => ({ body: { data: observer.getOpenPrs() } }),
29
+ 'GET /webui/github/pr/:number': async ({ params }) => {
30
+ // PR number is untrusted route input: only a positive integer may reach
31
+ // the observer (which shells `gh`). Reject anything else with a clean 400
32
+ // rather than letting it fall through to a lookup.
33
+ if (!/^\d+$/.test(String(params.number))) {
34
+ throw httpError(400, 'INVALID_PR_NUMBER', 'PR number must be a positive integer');
35
+ }
36
+ return { body: await observer.getPrStatus(Number(params.number)) };
37
+ },
27
38
  };
28
39
  }
29
40