@evomap/evolver 1.87.3 → 1.87.4
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/package.json +1 -1
- package/src/adapters/scripts/_runtimePaths.js +178 -1
- package/src/adapters/scripts/evolver-session-end.js +63 -33
- package/src/adapters/scripts/evolver-session-start.js +127 -43
- package/src/evolve/guards.js +1 -1
- package/src/evolve/pipeline/collect.js +1 -1
- package/src/evolve/pipeline/dispatch.js +1 -1
- package/src/evolve/pipeline/enrich.js +1 -1
- package/src/evolve/pipeline/hub.js +1 -1
- package/src/evolve/pipeline/select.js +1 -1
- package/src/evolve/pipeline/signals.js +1 -1
- package/src/evolve/utils.js +1 -1
- package/src/evolve.js +1 -1
- package/src/gep/a2aProtocol.js +1 -1
- package/src/gep/candidateEval.js +1 -1
- package/src/gep/candidates.js +1 -1
- package/src/gep/contentHash.js +1 -1
- package/src/gep/crypto.js +1 -1
- package/src/gep/curriculum.js +1 -1
- package/src/gep/deviceId.js +1 -1
- package/src/gep/envFingerprint.js +1 -1
- package/src/gep/epigenetics.js +1 -1
- package/src/gep/explore.js +1 -1
- package/src/gep/hash.js +1 -1
- package/src/gep/hubFetch.js +1 -1
- package/src/gep/hubReview.js +1 -1
- package/src/gep/hubSearch.js +1 -1
- package/src/gep/hubVerify.js +1 -1
- package/src/gep/idleScheduler.js +155 -6
- package/src/gep/learningSignals.js +1 -1
- package/src/gep/memoryGraph.js +1 -1
- package/src/gep/memoryGraphAdapter.js +1 -1
- package/src/gep/mutation.js +1 -1
- package/src/gep/narrativeMemory.js +1 -1
- package/src/gep/openPRRegistry.js +1 -1
- package/src/gep/personality.js +1 -1
- package/src/gep/policyCheck.js +1 -1
- package/src/gep/prompt.js +1 -1
- package/src/gep/recallVerifier.js +1 -1
- package/src/gep/reflection.js +1 -1
- package/src/gep/selector.js +1 -1
- package/src/gep/skillDistiller.js +1 -1
- package/src/gep/solidify.js +1 -1
- package/src/gep/strategy.js +1 -1
- package/src/gep/workspaceKeychain.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evomap/evolver",
|
|
3
|
-
"version": "1.87.
|
|
3
|
+
"version": "1.87.4",
|
|
4
4
|
"description": "A GEP-powered self-evolution engine for AI agents. Features automated log analysis and Genome Evolution Protocol (GEP) for auditable, reusable evolution assets.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
const fs = require('fs');
|
|
20
20
|
const path = require('path');
|
|
21
21
|
const os = require('os');
|
|
22
|
+
const { spawnSync } = require('child_process');
|
|
22
23
|
|
|
23
24
|
function isEvolverPackageJson(filePath) {
|
|
24
25
|
try {
|
|
@@ -80,6 +81,162 @@ function findEvolverRoot() {
|
|
|
80
81
|
return null;
|
|
81
82
|
}
|
|
82
83
|
|
|
84
|
+
// Resolve the user's PROJECT directory — the workspace the agent is actually
|
|
85
|
+
// working in — for git-diff collection and workspace tagging.
|
|
86
|
+
//
|
|
87
|
+
// Why this exists: hook scripts must NOT assume `process.cwd()` is the project
|
|
88
|
+
// root. Cursor invokes some hook events (e.g. afterFileEdit) with the working
|
|
89
|
+
// directory set to the *plugin* install dir (`~/.cursor/plugins/local/<name>`),
|
|
90
|
+
// not the opened workspace. A hook that runs `git diff` in cwd would then look
|
|
91
|
+
// for changes in the plugin directory and find none — silently recording
|
|
92
|
+
// nothing for every task. Hosts expose the real workspace root via an env var:
|
|
93
|
+
// - Cursor sets CURSOR_PROJECT_DIR (and a CLAUDE_PROJECT_DIR compat alias)
|
|
94
|
+
// - Claude Code sets CLAUDE_PROJECT_DIR
|
|
95
|
+
// Codex / opencode / Kiro and direct CLI usage leave both unset, in which case
|
|
96
|
+
// `process.cwd()` is already the project root and remains the fallback — so
|
|
97
|
+
// this change is a no-op on those platforms.
|
|
98
|
+
//
|
|
99
|
+
// SECURITY: only honor an env value that points at an existing directory. A
|
|
100
|
+
// stale or empty value must not redirect git collection to a bogus path; we
|
|
101
|
+
// fall through to cwd instead. We intentionally do NOT recurse into evolver
|
|
102
|
+
// package discovery here — this is purely "where is the user's code".
|
|
103
|
+
function resolveProjectDir() {
|
|
104
|
+
for (const key of ['CURSOR_PROJECT_DIR', 'CLAUDE_PROJECT_DIR']) {
|
|
105
|
+
const v = process.env[key];
|
|
106
|
+
if (typeof v === 'string' && v.trim()) {
|
|
107
|
+
try {
|
|
108
|
+
if (fs.statSync(v).isDirectory()) return v;
|
|
109
|
+
} catch { /* not a usable dir — try next / fall back to cwd */ }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return process.cwd();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Determine the workspace ROOT for a project, mirroring src/gep/paths.js
|
|
116
|
+
// getWorkspaceRoot() step-for-step so the FS-only fallback lands its secret at
|
|
117
|
+
// the SAME path paths.js would (what lets an installed @evomap/evolver read the
|
|
118
|
+
// very same id):
|
|
119
|
+
// 1. OPENCLAW_WORKSPACE override.
|
|
120
|
+
// 2. else the git repo root at/above projectDir, BUT if that repo root has a
|
|
121
|
+
// `workspace/` subdirectory, paths.js returns <repoRoot>/workspace — so we
|
|
122
|
+
// must too, or the two land on different .evolver/workspace-id files (the
|
|
123
|
+
// "read back identically" guarantee would break for such projects).
|
|
124
|
+
// 3. else projectDir.
|
|
125
|
+
function _fsWorkspaceRoot(projectDir) {
|
|
126
|
+
if (process.env.OPENCLAW_WORKSPACE) return process.env.OPENCLAW_WORKSPACE;
|
|
127
|
+
// Walk up from projectDir looking for a .git entry (file or dir) = repo root.
|
|
128
|
+
let repoRoot = null;
|
|
129
|
+
let dir = projectDir;
|
|
130
|
+
while (dir) {
|
|
131
|
+
if (fs.existsSync(path.join(dir, '.git'))) { repoRoot = dir; break; }
|
|
132
|
+
const parent = path.dirname(dir);
|
|
133
|
+
if (parent === dir) break;
|
|
134
|
+
dir = parent;
|
|
135
|
+
}
|
|
136
|
+
if (!repoRoot) return projectDir;
|
|
137
|
+
// Mirror getWorkspaceRoot()'s workspace/ subdir step.
|
|
138
|
+
const workspaceDir = path.join(repoRoot, 'workspace');
|
|
139
|
+
if (fs.existsSync(workspaceDir)) return workspaceDir;
|
|
140
|
+
return repoRoot;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// FS-only re-implementation of src/gep/paths.js getWorkspaceId() for the case
|
|
144
|
+
// where the evolver package is not installed (plugin-only installs). It reads
|
|
145
|
+
// — and lazily, atomically creates — the per-workspace secret at
|
|
146
|
+
// <workspaceRoot>/.evolver/workspace-id. The format (16-byte hex), the path,
|
|
147
|
+
// the 0600 mode, the O_EXCL|O_NOFOLLOW atomic create, and the symlink
|
|
148
|
+
// rejection all match paths.js exactly, so a workspace seeded by this fallback
|
|
149
|
+
// is transparently picked up by paths.getWorkspaceId() once the package is
|
|
150
|
+
// present, and vice-versa. Returns null on any read/write error (caller then
|
|
151
|
+
// falls back to legacy cwd-tag matching — no regression).
|
|
152
|
+
// Read <dir>/workspace-id with the same symlink guards paths.js'
|
|
153
|
+
// _readWorkspaceIdFromFs uses: reject a symlinked .evolver dir, reject a
|
|
154
|
+
// symlinked / non-regular id file, and require hex format. Returns the id, or
|
|
155
|
+
// null on any error / missing file. Used for BOTH the initial read and the
|
|
156
|
+
// EEXIST race re-read so a symlink swapped in between our lstat and openSync
|
|
157
|
+
// can never be followed (Bugbot PR #557).
|
|
158
|
+
function _readWsIdGuarded(dir, file) {
|
|
159
|
+
try {
|
|
160
|
+
const dirStat = fs.lstatSync(dir, { throwIfNoEntry: false });
|
|
161
|
+
if (dirStat && dirStat.isSymbolicLink()) return null;
|
|
162
|
+
const fileStat = fs.lstatSync(file, { throwIfNoEntry: false });
|
|
163
|
+
if (!fileStat) return null;
|
|
164
|
+
if (fileStat.isSymbolicLink() || !fileStat.isFile()) return null;
|
|
165
|
+
const raw = fs.readFileSync(file, 'utf8').trim();
|
|
166
|
+
return raw && /^[a-f0-9]{32,}$/i.test(raw) ? raw : null;
|
|
167
|
+
} catch { return null; }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function _fsWorkspaceId(projectDir) {
|
|
171
|
+
// Whole body is wrapped: the documented contract is "returns null on ANY
|
|
172
|
+
// read/write error" so the session-start/-end hooks degrade gracefully
|
|
173
|
+
// rather than crash. throwIfNoEntry:false only suppresses ENOENT; EACCES/EIO
|
|
174
|
+
// and friends still throw, so a bare lstat/mkdir here must not escape
|
|
175
|
+
// (Bugbot PR #557 round-2 — an unguarded lstat could crash the hook).
|
|
176
|
+
try {
|
|
177
|
+
const dir = path.join(_fsWorkspaceRoot(projectDir), '.evolver');
|
|
178
|
+
const file = path.join(dir, 'workspace-id');
|
|
179
|
+
// Read first, with symlink guards.
|
|
180
|
+
const existing = _readWsIdGuarded(dir, file);
|
|
181
|
+
if (existing) return existing;
|
|
182
|
+
// If the file exists but the guards rejected it (symlink / bad format),
|
|
183
|
+
// refuse rather than create over it.
|
|
184
|
+
if (fs.lstatSync(file, { throwIfNoEntry: false })) return null;
|
|
185
|
+
// Missing — create atomically. Refuse a symlinked .evolver dir (O_NOFOLLOW
|
|
186
|
+
// only guards the final component, not intermediate dirs).
|
|
187
|
+
const dirStat = fs.lstatSync(dir, { throwIfNoEntry: false });
|
|
188
|
+
if (dirStat && dirStat.isSymbolicLink()) return null;
|
|
189
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
190
|
+
const payload = require('crypto').randomBytes(16).toString('hex');
|
|
191
|
+
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL |
|
|
192
|
+
(fs.constants.O_NOFOLLOW || 0);
|
|
193
|
+
let fd;
|
|
194
|
+
try {
|
|
195
|
+
fd = fs.openSync(file, flags, 0o600);
|
|
196
|
+
} catch (e) {
|
|
197
|
+
// Lost a race — re-read WITH the same symlink guards (paths.js does the
|
|
198
|
+
// same). A bare readFileSync here would follow a symlink swapped in
|
|
199
|
+
// after our dir lstat (Bugbot PR #557).
|
|
200
|
+
if (e && e.code === 'EEXIST') return _readWsIdGuarded(dir, file);
|
|
201
|
+
return null; // ELOOP/EMLINK from O_NOFOLLOW hitting a symlink — refuse.
|
|
202
|
+
}
|
|
203
|
+
try { fs.writeSync(fd, payload + '\n', 0, 'utf8'); } finally { fs.closeSync(fd); }
|
|
204
|
+
try { fs.chmodSync(file, 0o600); } catch { /* best-effort */ }
|
|
205
|
+
return payload;
|
|
206
|
+
} catch { return null; }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Resolve the current workspace id — the forge-resistant tag the session-end
|
|
210
|
+
// writer stamps on every memory-graph entry (`workspace_id`). This is the
|
|
211
|
+
// SINGLE source of that resolution: the session-end writer stamps it and the
|
|
212
|
+
// session-start reader scopes by it, so both call this one function. Keeping
|
|
213
|
+
// it here (rather than a copy per hook) is what guarantees reader and writer
|
|
214
|
+
// can never drift apart — if they resolved different ids, no entry would ever
|
|
215
|
+
// match the reader's filter and workspace scoping would silently break.
|
|
216
|
+
// Resolution order:
|
|
217
|
+
// 1. EVOLVER_WORKSPACE_ID env override
|
|
218
|
+
// 2. paths.getWorkspaceId() loaded from the resolved evolver root (this is
|
|
219
|
+
// the richer path — it can additionally back the secret with the OS
|
|
220
|
+
// keychain when @napi-rs/keyring is installed).
|
|
221
|
+
// 3. FS-only fallback for plugin-only installs where the evolver package is
|
|
222
|
+
// not reachable. Without this, plugin users got workspace_id=null and the
|
|
223
|
+
// forge-resistant scoping silently degraded to cwd-tag matching (found
|
|
224
|
+
// via real-Cursor end-to-end testing). The fallback writes the same
|
|
225
|
+
// secret file paths.js uses, so installing the package later is seamless.
|
|
226
|
+
// Still returns null if even the FS write fails — callers must then NOT filter
|
|
227
|
+
// (show everything), preserving prior behavior rather than hiding all memory.
|
|
228
|
+
function resolveWorkspaceId(evolverRoot, projectDir) {
|
|
229
|
+
if (process.env.EVOLVER_WORKSPACE_ID) return String(process.env.EVOLVER_WORKSPACE_ID);
|
|
230
|
+
const root = evolverRoot || findEvolverRoot();
|
|
231
|
+
if (root) {
|
|
232
|
+
try {
|
|
233
|
+
const paths = require(path.join(root, 'src', 'gep', 'paths.js'));
|
|
234
|
+
if (typeof paths.getWorkspaceId === 'function') return paths.getWorkspaceId();
|
|
235
|
+
} catch { /* paths.js unreachable — fall through to FS-only */ }
|
|
236
|
+
}
|
|
237
|
+
return _fsWorkspaceId(projectDir || resolveProjectDir());
|
|
238
|
+
}
|
|
239
|
+
|
|
83
240
|
// Returns a path to the evolution memory graph, or a fallback location that
|
|
84
241
|
// is guaranteed to be writable. Never returns null — when no evolver root is
|
|
85
242
|
// available, we fall back to `~/.evolver/memory/evolution/memory_graph.jsonl`
|
|
@@ -111,4 +268,24 @@ function findMemoryGraph(evolverRoot) {
|
|
|
111
268
|
return path.join(userDir, 'memory_graph.jsonl');
|
|
112
269
|
}
|
|
113
270
|
|
|
114
|
-
|
|
271
|
+
// Is `dir` inside a git work tree? Cheap, no-shell `git rev-parse`. Returns
|
|
272
|
+
// false on any error (git missing, not a repo, timeout) and never throws — the
|
|
273
|
+
// session-start hook uses this only to decide whether to surface a one-line
|
|
274
|
+
// "evolver needs a git workspace" notice, so a false negative just suppresses
|
|
275
|
+
// the notice rather than breaking anything.
|
|
276
|
+
function isGitWorkspace(dir) {
|
|
277
|
+
try {
|
|
278
|
+
const res = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], {
|
|
279
|
+
cwd: dir,
|
|
280
|
+
encoding: 'utf8',
|
|
281
|
+
timeout: 5000,
|
|
282
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
283
|
+
shell: false,
|
|
284
|
+
});
|
|
285
|
+
return res.status === 0 && typeof res.stdout === 'string' && res.stdout.trim() === 'true';
|
|
286
|
+
} catch {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
module.exports = { findEvolverRoot, findMemoryGraph, resolveProjectDir, resolveWorkspaceId, isGitWorkspace };
|
|
@@ -13,27 +13,16 @@ const { spawnSync } = require('child_process');
|
|
|
13
13
|
// on large repos). See GHSA reports / issue #451.
|
|
14
14
|
const MAX_EXEC_BUFFER = 10 * 1024 * 1024;
|
|
15
15
|
|
|
16
|
-
const { findEvolverRoot, findMemoryGraph } = require('./_runtimePaths');
|
|
16
|
+
const { findEvolverRoot, findMemoryGraph, resolveProjectDir, resolveWorkspaceId } = require('./_runtimePaths');
|
|
17
17
|
|
|
18
|
-
// Workspace-id
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// round-1 MEDIUM
|
|
26
|
-
// evolver root; fall back to env-only when paths.js is unreachable.
|
|
27
|
-
function resolveWorkspaceIdForWriter() {
|
|
28
|
-
if (process.env.EVOLVER_WORKSPACE_ID) return String(process.env.EVOLVER_WORKSPACE_ID);
|
|
29
|
-
const evolverRoot = findEvolverRoot();
|
|
30
|
-
if (!evolverRoot) return null;
|
|
31
|
-
try {
|
|
32
|
-
const paths = require(path.join(evolverRoot, 'src', 'gep', 'paths.js'));
|
|
33
|
-
if (typeof paths.getWorkspaceId === 'function') return paths.getWorkspaceId();
|
|
34
|
-
} catch { /* paths.js unreachable — return null */ }
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
18
|
+
// Workspace-id resolution is shared with the session-start reader via
|
|
19
|
+
// _runtimePaths.resolveWorkspaceId(). Reader and writer MUST resolve the SAME
|
|
20
|
+
// id or workspace scoping silently breaks (no entry would ever match the
|
|
21
|
+
// reader's filter), so this logic lives in exactly one place instead of being
|
|
22
|
+
// duplicated here. The shared resolver mirrors src/gep/paths.js#getWorkspaceId()
|
|
23
|
+
// loaded from the evolver root, with an EVOLVER_WORKSPACE_ID env override —
|
|
24
|
+
// consistent with the review-time reader in src/evolve/pipeline/collect.js
|
|
25
|
+
// (Bugbot PR #109 round-1 MEDIUM; reader/writer drift flagged on PR #555).
|
|
37
26
|
|
|
38
27
|
function runGit(args, cwd) {
|
|
39
28
|
// Argv-array form, no shell. Avoids POSIX `2>/dev/null` redirects that
|
|
@@ -55,7 +44,9 @@ function runGit(args, cwd) {
|
|
|
55
44
|
}
|
|
56
45
|
|
|
57
46
|
function getGitDiffStats() {
|
|
58
|
-
|
|
47
|
+
// Use the host-provided workspace root, not process.cwd(): Cursor runs some
|
|
48
|
+
// hook events with cwd set to the plugin dir, where `git diff` finds nothing.
|
|
49
|
+
const cwd = resolveProjectDir();
|
|
59
50
|
// Distinguish "git failed (no HEAD~1, etc.)" from "git succeeded with
|
|
60
51
|
// empty output (e.g. empty merge)". The previous `||` chain treated
|
|
61
52
|
// both as falsy and fell through to the working-tree diff, which can
|
|
@@ -67,11 +58,16 @@ function getGitDiffStats() {
|
|
|
67
58
|
const filesChanged = (stat.match(/\d+ files? changed/) || ['0'])[0];
|
|
68
59
|
const insertions = (stat.match(/(\d+) insertions?/) || [null, '0'])[1];
|
|
69
60
|
const deletions = (stat.match(/(\d+) deletions?/) || [null, '0'])[1];
|
|
61
|
+
// Distinguish "no git repo here" from "repo with no changes" purely for the
|
|
62
|
+
// skip-log message — the diff commands above can't tell the two apart (both
|
|
63
|
+
// yield empty output). A single cheap rev-parse settles it.
|
|
64
|
+
const isRepo = runGit(['rev-parse', '--is-inside-work-tree'], cwd).out === 'true';
|
|
70
65
|
return {
|
|
71
66
|
stat,
|
|
72
67
|
summary: `${filesChanged}, +${insertions}/-${deletions}`,
|
|
73
68
|
diffSnippet: diffContent.slice(0, 2000),
|
|
74
69
|
hasChanges: stat.length > 0,
|
|
70
|
+
isRepo,
|
|
75
71
|
};
|
|
76
72
|
}
|
|
77
73
|
|
|
@@ -152,6 +148,10 @@ function recordToHub(outcome) {
|
|
|
152
148
|
|
|
153
149
|
function recordToLocal(graphPath, outcome) {
|
|
154
150
|
try {
|
|
151
|
+
// Resolve the project dir once so the cwd tag and the workspace_id secret
|
|
152
|
+
// share a single, consistent source (both must agree with the session-start
|
|
153
|
+
// reader's resolveProjectDir()-based scoping).
|
|
154
|
+
const projectDir = resolveProjectDir();
|
|
155
155
|
const entry = {
|
|
156
156
|
timestamp: new Date().toISOString(),
|
|
157
157
|
gene_id: outcome.geneId || 'ad_hoc',
|
|
@@ -174,8 +174,17 @@ function recordToLocal(graphPath, outcome) {
|
|
|
174
174
|
// .evolver/workspace-id file. cwd is retained as a backward-compat
|
|
175
175
|
// tag so older entries written before this hardening still pass
|
|
176
176
|
// the cwd check.
|
|
177
|
-
|
|
178
|
-
|
|
177
|
+
//
|
|
178
|
+
// Use resolveProjectDir() (NOT process.cwd()) so the cwd tag records the
|
|
179
|
+
// user's project, consistent with how the diff above is collected and
|
|
180
|
+
// with the session-start reader's cwd fallback. Under Cursor, cwd is the
|
|
181
|
+
// plugin install dir, so a raw process.cwd() tag would never match the
|
|
182
|
+
// reader's resolveProjectDir()-derived currentDir — silently hiding every
|
|
183
|
+
// cwd-only entry (Bugbot PR #555). collect.js only uses cwd as a legacy
|
|
184
|
+
// fallback (disabled once a workspace_id secret exists), so changing the
|
|
185
|
+
// tag's source — still a directory path — does not affect its scoping.
|
|
186
|
+
cwd: projectDir,
|
|
187
|
+
workspace_id: resolveWorkspaceId(undefined, projectDir),
|
|
179
188
|
source: 'hook:session-end',
|
|
180
189
|
};
|
|
181
190
|
fs.appendFileSync(graphPath, JSON.stringify(entry) + '\n', 'utf8');
|
|
@@ -185,6 +194,24 @@ function recordToLocal(graphPath, outcome) {
|
|
|
185
194
|
}
|
|
186
195
|
}
|
|
187
196
|
|
|
197
|
+
// Append a single timestamped line to ~/.evolver/logs/evolution.log (or
|
|
198
|
+
// EVOLVER_HOOK_LOG_DIR). Best-effort: a log-write failure must never break the
|
|
199
|
+
// hook. Used both for recorded outcomes and for the "skipped, nothing to
|
|
200
|
+
// record" notices so a user can always see why a session did or did not
|
|
201
|
+
// produce an entry.
|
|
202
|
+
function appendEvolutionLog(line) {
|
|
203
|
+
try {
|
|
204
|
+
const logDir = process.env.EVOLVER_HOOK_LOG_DIR
|
|
205
|
+
|| path.join(os.homedir(), '.evolver', 'logs');
|
|
206
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
207
|
+
fs.appendFileSync(
|
|
208
|
+
path.join(logDir, 'evolution.log'),
|
|
209
|
+
`${new Date().toISOString()} ${line}\n`,
|
|
210
|
+
'utf8'
|
|
211
|
+
);
|
|
212
|
+
} catch { /* best-effort, never break the hook on log write */ }
|
|
213
|
+
}
|
|
214
|
+
|
|
188
215
|
function main() {
|
|
189
216
|
let inputData = '';
|
|
190
217
|
let handled = false;
|
|
@@ -204,6 +231,18 @@ function main() {
|
|
|
204
231
|
const diffInfo = getGitDiffStats();
|
|
205
232
|
|
|
206
233
|
if (!diffInfo.hasChanges) {
|
|
234
|
+
// No git diff means no signal source — session-end derives the
|
|
235
|
+
// outcome (status/score/signals/summary) entirely from the diff, so
|
|
236
|
+
// there is nothing meaningful to record. This is expected in a
|
|
237
|
+
// non-git workspace or a repo with no changes this session. Rather
|
|
238
|
+
// than fabricate an empty outcome (which would pollute the memory
|
|
239
|
+
// graph), record nothing — but leave a log breadcrumb so the user
|
|
240
|
+
// can tell "evolver ran but had nothing to record" apart from
|
|
241
|
+
// "evolver never fired". (Previously this branch was fully silent.)
|
|
242
|
+
const reason = diffInfo.isRepo
|
|
243
|
+
? 'no changes detected this session'
|
|
244
|
+
: 'not a git workspace';
|
|
245
|
+
appendEvolutionLog(`[Evolution] Session end: nothing recorded (${reason}).`);
|
|
207
246
|
finish({});
|
|
208
247
|
return;
|
|
209
248
|
}
|
|
@@ -254,16 +293,7 @@ function main() {
|
|
|
254
293
|
// The receipt is always appended to ~/.evolver/logs/evolution.log
|
|
255
294
|
// so it is never silently lost; users can opt back in to the inline
|
|
256
295
|
// notification with EVOLVER_HOOK_VERBOSE=1.
|
|
257
|
-
|
|
258
|
-
const logDir = process.env.EVOLVER_HOOK_LOG_DIR
|
|
259
|
-
|| path.join(os.homedir(), '.evolver', 'logs');
|
|
260
|
-
fs.mkdirSync(logDir, { recursive: true });
|
|
261
|
-
fs.appendFileSync(
|
|
262
|
-
path.join(logDir, 'evolution.log'),
|
|
263
|
-
`${new Date().toISOString()} ${msg}\n`,
|
|
264
|
-
'utf8'
|
|
265
|
-
);
|
|
266
|
-
} catch { /* best-effort, never break the hook on log write */ }
|
|
296
|
+
appendEvolutionLog(msg);
|
|
267
297
|
|
|
268
298
|
finish(isCursorHost() ? {} : { systemMessage: msg });
|
|
269
299
|
} catch (e) {
|
|
@@ -7,17 +7,76 @@ const fs = require('fs');
|
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const os = require('os');
|
|
9
9
|
|
|
10
|
-
const { findEvolverRoot, findMemoryGraph } = require('./_runtimePaths');
|
|
10
|
+
const { findEvolverRoot, findMemoryGraph, resolveProjectDir, resolveWorkspaceId, isGitWorkspace } = require('./_runtimePaths');
|
|
11
11
|
const { filterRelevantOutcomes } = require('./_memoryFiltering');
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
// One-line notice shown (throttled) when the workspace is not a git repo.
|
|
14
|
+
// Evolver derives every outcome from the git diff, so in a non-git folder the
|
|
15
|
+
// session-end hook records nothing — silently, unless we say so here. We surface
|
|
16
|
+
// it in session-start's additionalContext (injected as opening context, which
|
|
17
|
+
// does NOT trigger an extra inference round, unlike a stop-hook systemMessage).
|
|
18
|
+
const NON_GIT_NOTICE =
|
|
19
|
+
'[Evolver] This folder is not a git repository, so evolution memory is inactive ' +
|
|
20
|
+
'(outcomes are derived from git diffs). Run `git init` here, or open a git project, ' +
|
|
21
|
+
'to enable recall and recording.';
|
|
22
|
+
const NON_GIT_NOTICE_TTL_MS = 30 * 60 * 1000; // once per 30 min per folder
|
|
23
|
+
|
|
24
|
+
// Return up to `n` of the current workspace's most-recent entries, in
|
|
25
|
+
// chronological (oldest-first) order.
|
|
26
|
+
//
|
|
27
|
+
// Why scan from the end: a plain tail-N-then-filter read would let outcomes
|
|
28
|
+
// from other projects (which share the user-level fallback graph on npm-global
|
|
29
|
+
// installs) crowd this workspace's entries out of the window — we must scope
|
|
30
|
+
// to the workspace BEFORE trimming. But parsing the ENTIRE file to do that is
|
|
31
|
+
// wasteful: the graph can reach ~100 MB before rotation, and JSON-parsing every
|
|
32
|
+
// line on each session start is real CPU/memory cost (Bugbot PR #555 round-3).
|
|
33
|
+
//
|
|
34
|
+
// So we read the file (cheap; the previous readLastN read it whole too) but
|
|
35
|
+
// JSON-parse lines lazily from the newest end, keeping only workspace matches,
|
|
36
|
+
// and stop as soon as we have `n`. Parse count is bounded by where this
|
|
37
|
+
// workspace's n-th-most-recent entry sits, not by total file size.
|
|
38
|
+
function readRecentWorkspaceEntries(filePath, currentId, currentDir, n) {
|
|
39
|
+
let lines;
|
|
14
40
|
try {
|
|
15
|
-
|
|
16
|
-
const lines = content.trim().split('\n').filter(Boolean);
|
|
17
|
-
return lines.slice(-n).map(line => {
|
|
18
|
-
try { return JSON.parse(line); } catch { return null; }
|
|
19
|
-
}).filter(Boolean);
|
|
41
|
+
lines = fs.readFileSync(filePath, 'utf8').trim().split('\n');
|
|
20
42
|
} catch { return []; }
|
|
43
|
+
const out = [];
|
|
44
|
+
for (let i = lines.length - 1; i >= 0 && out.length < n; i--) {
|
|
45
|
+
const line = lines[i];
|
|
46
|
+
if (!line) continue;
|
|
47
|
+
let entry;
|
|
48
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
49
|
+
if (belongsToWorkspace(entry, currentId, currentDir)) out.push(entry);
|
|
50
|
+
}
|
|
51
|
+
return out.reverse(); // newest-collected-first -> chronological
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Does this memory-graph entry belong to the current workspace?
|
|
55
|
+
//
|
|
56
|
+
// The session-end writer stamps two tags: `workspace_id` (forge-resistant,
|
|
57
|
+
// preferred) and `cwd` (backward-compat). We scope reads so that one project
|
|
58
|
+
// never sees another's outcomes through the shared user-level fallback graph
|
|
59
|
+
// (~/.evolver/memory/evolution/memory_graph.jsonl) — the cross-project
|
|
60
|
+
// disclosure / prompt-injection surface Bugbot flagged on the writer side
|
|
61
|
+
// (PR #105 round-2), which the reader never enforced until now.
|
|
62
|
+
//
|
|
63
|
+
// Rules, in order:
|
|
64
|
+
// - currentId known + entry.workspace_id present -> must match exactly.
|
|
65
|
+
// - currentId unknown OR entry has neither tag (pre-hardening / Hub-sourced
|
|
66
|
+
// entries) -> do NOT exclude; falling back to "show it" preserves prior
|
|
67
|
+
// behavior and avoids hiding all memory when ids can't be resolved.
|
|
68
|
+
// - As a softer fallback, when the entry has no workspace_id but does carry a
|
|
69
|
+
// cwd, match that against the current project dir.
|
|
70
|
+
function belongsToWorkspace(entry, currentId, currentDir) {
|
|
71
|
+
if (entry && typeof entry.workspace_id === 'string' && entry.workspace_id) {
|
|
72
|
+
if (currentId) return entry.workspace_id === currentId;
|
|
73
|
+
return true; // can't compare — don't hide it
|
|
74
|
+
}
|
|
75
|
+
if (entry && typeof entry.cwd === 'string' && entry.cwd) {
|
|
76
|
+
if (currentDir) return entry.cwd === currentDir;
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
return true; // untagged (legacy / Hub) — never excluded
|
|
21
80
|
}
|
|
22
81
|
|
|
23
82
|
function formatOutcome(entry) {
|
|
@@ -46,18 +105,14 @@ function getDedupStatePath() {
|
|
|
46
105
|
return path.join(dir, 'session-start-state.json');
|
|
47
106
|
}
|
|
48
107
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const ttlMs = Number(process.env.EVOLVER_SESSION_START_DEDUP_TTL_MS) || (30 * 60 * 1000);
|
|
58
|
-
const key = process.cwd();
|
|
108
|
+
// TTL throttle keyed by an arbitrary string, persisted in session-start-state
|
|
109
|
+
// .json. Returns true if `key` fired within the last `ttlMs` (caller should
|
|
110
|
+
// suppress); otherwise records "now" for `key` and returns false. Best-effort:
|
|
111
|
+
// a state read/write failure just means no throttling (fail open). Shared by
|
|
112
|
+
// the Kiro per-prompt dedup and the non-git notice so both age out of the same
|
|
113
|
+
// file (entries older than 24h are pruned on write).
|
|
114
|
+
function throttled(key, ttlMs) {
|
|
59
115
|
const statePath = getDedupStatePath();
|
|
60
|
-
|
|
61
116
|
let state = {};
|
|
62
117
|
try {
|
|
63
118
|
if (fs.existsSync(statePath)) {
|
|
@@ -67,9 +122,7 @@ function shouldSkipInjection() {
|
|
|
67
122
|
|
|
68
123
|
const now = Date.now();
|
|
69
124
|
const last = state[key];
|
|
70
|
-
if (typeof last === 'number' && now - last < ttlMs)
|
|
71
|
-
return true;
|
|
72
|
-
}
|
|
125
|
+
if (typeof last === 'number' && now - last < ttlMs) return true;
|
|
73
126
|
|
|
74
127
|
state[key] = now;
|
|
75
128
|
try {
|
|
@@ -82,47 +135,78 @@ function shouldSkipInjection() {
|
|
|
82
135
|
fs.writeFileSync(tmp, JSON.stringify(state), 'utf8');
|
|
83
136
|
fs.renameSync(tmp, statePath);
|
|
84
137
|
} catch { /* best-effort */ }
|
|
85
|
-
|
|
86
138
|
return false;
|
|
87
139
|
}
|
|
88
140
|
|
|
141
|
+
function shouldSkipInjection() {
|
|
142
|
+
// Only apply dedup when explicitly enabled (set by Kiro adapter) OR when
|
|
143
|
+
// we detect a per-prompt-firing platform via PROMPT_SUBMIT heuristic in
|
|
144
|
+
// stdin. The stdin is drained in main(), so we rely on env flag here.
|
|
145
|
+
const dedupEnabled = String(process.env.EVOLVER_SESSION_START_DEDUP || '').toLowerCase() === '1'
|
|
146
|
+
|| String(process.env.EVOLVER_SESSION_START_DEDUP || '').toLowerCase() === 'true';
|
|
147
|
+
if (!dedupEnabled) return false;
|
|
148
|
+
|
|
149
|
+
const ttlMs = Number(process.env.EVOLVER_SESSION_START_DEDUP_TTL_MS) || (30 * 60 * 1000);
|
|
150
|
+
return throttled(process.cwd(), ttlMs);
|
|
151
|
+
}
|
|
152
|
+
|
|
89
153
|
function main() {
|
|
90
154
|
if (shouldSkipInjection()) {
|
|
91
155
|
process.stdout.write(JSON.stringify({}));
|
|
92
156
|
return;
|
|
93
157
|
}
|
|
94
158
|
|
|
159
|
+
const currentDir = resolveProjectDir();
|
|
160
|
+
|
|
161
|
+
// Non-git notice: evolver records nothing in a non-git folder (outcomes come
|
|
162
|
+
// from git diffs), so tell the user — once per folder per TTL — instead of
|
|
163
|
+
// failing silently. Emitted regardless of whether any memory exists below.
|
|
164
|
+
const parts = [];
|
|
165
|
+
if (!isGitWorkspace(currentDir) && !throttled('nongit:' + currentDir, NON_GIT_NOTICE_TTL_MS)) {
|
|
166
|
+
parts.push(NON_GIT_NOTICE);
|
|
167
|
+
}
|
|
168
|
+
|
|
95
169
|
const evolverRoot = findEvolverRoot();
|
|
96
170
|
const graphPath = findMemoryGraph(evolverRoot);
|
|
97
171
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
172
|
+
// Scope to the current workspace BEFORE trimming to the most-recent window,
|
|
173
|
+
// so other projects sharing the user-level fallback graph can't crowd this
|
|
174
|
+
// workspace's outcomes out of view. When the workspace id can't be resolved,
|
|
175
|
+
// belongsToWorkspace() falls back to "show it" — no regression vs. the old
|
|
176
|
+
// unscoped behavior.
|
|
177
|
+
if (graphPath) {
|
|
178
|
+
const currentId = resolveWorkspaceId(evolverRoot, currentDir);
|
|
179
|
+
const recent = readRecentWorkspaceEntries(graphPath, currentId, currentDir, 5);
|
|
180
|
+
const filtered = filterRelevantOutcomes(recent);
|
|
181
|
+
if (filtered.length > 0) {
|
|
182
|
+
const successCount = filtered.filter(e => e.outcome && e.outcome.status === 'success').length;
|
|
183
|
+
const failCount = filtered.filter(e => e.outcome && e.outcome.status === 'failed').length;
|
|
184
|
+
parts.push([
|
|
185
|
+
`[Evolution Memory] Recent ${filtered.length} outcomes (${successCount} success, ${failCount} failed):`,
|
|
186
|
+
...filtered.map(formatOutcome),
|
|
187
|
+
'',
|
|
188
|
+
'Use successful approaches. Avoid repeating failed patterns.',
|
|
189
|
+
].join('\n'));
|
|
190
|
+
}
|
|
101
191
|
}
|
|
102
192
|
|
|
103
|
-
|
|
104
|
-
const filtered = filterRelevantOutcomes(entries);
|
|
105
|
-
|
|
106
|
-
if (filtered.length === 0) {
|
|
193
|
+
if (parts.length === 0) {
|
|
107
194
|
process.stdout.write(JSON.stringify({}));
|
|
108
195
|
return;
|
|
109
196
|
}
|
|
110
197
|
|
|
111
|
-
const
|
|
112
|
-
const failCount = filtered.filter(e => e.outcome && e.outcome.status === 'failed').length;
|
|
113
|
-
|
|
114
|
-
const lines = filtered.map(formatOutcome);
|
|
115
|
-
const summary = [
|
|
116
|
-
`[Evolution Memory] Recent ${filtered.length} outcomes (${successCount} success, ${failCount} failed):`,
|
|
117
|
-
...lines,
|
|
118
|
-
'',
|
|
119
|
-
'Use successful approaches. Avoid repeating failed patterns.',
|
|
120
|
-
].join('\n');
|
|
121
|
-
|
|
198
|
+
const out = parts.join('\n\n');
|
|
122
199
|
process.stdout.write(JSON.stringify({
|
|
123
|
-
agent_message:
|
|
124
|
-
additionalContext:
|
|
200
|
+
agent_message: out,
|
|
201
|
+
additionalContext: out,
|
|
125
202
|
}));
|
|
126
203
|
}
|
|
127
204
|
|
|
128
|
-
|
|
205
|
+
// Run as a hook when invoked directly; expose pure helpers for unit tests when
|
|
206
|
+
// required as a module. Guarding on require.main keeps the direct-execution
|
|
207
|
+
// behavior (the hosts run `node evolver-session-start.js`) unchanged.
|
|
208
|
+
if (require.main === module) {
|
|
209
|
+
main();
|
|
210
|
+
} else {
|
|
211
|
+
module.exports = { belongsToWorkspace };
|
|
212
|
+
}
|