agentsys 5.13.4 → 6.0.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 (39) hide show
  1. package/.claude-plugin/marketplace.json +28 -28
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -3
  4. package/AGENTS.md +8 -8
  5. package/CHANGELOG.md +34 -0
  6. package/README.md +11 -116
  7. package/lib/binary/index.js +8 -2
  8. package/lib/binary/shared-helpers.js +160 -0
  9. package/lib/collectors/codebase.js +7 -2
  10. package/lib/collectors/documentation.js +8 -2
  11. package/lib/enhance/agent-patterns.js +17 -4
  12. package/lib/enhance/auto-suppression.js +19 -7
  13. package/lib/enhance/cross-file-analyzer.js +11 -4
  14. package/lib/enhance/docs-patterns.js +6 -2
  15. package/lib/enhance/fixer.js +22 -5
  16. package/lib/enhance/skill-patterns.js +5 -5
  17. package/lib/index.js +2 -0
  18. package/lib/repo-intel/cache.js +171 -0
  19. package/lib/repo-intel/converter.js +130 -0
  20. package/lib/repo-intel/embed/binary.js +242 -0
  21. package/lib/repo-intel/embed/index.js +26 -0
  22. package/lib/repo-intel/embed/orchestrator.js +239 -0
  23. package/lib/repo-intel/embed/preference.js +136 -0
  24. package/lib/repo-intel/enrich.js +198 -0
  25. package/lib/repo-intel/index.js +370 -0
  26. package/lib/repo-intel/installer.js +78 -0
  27. package/lib/repo-intel/queries.js +213 -13
  28. package/lib/repo-intel/updater.js +104 -0
  29. package/lib/repo-map/index.js +19 -254
  30. package/package.json +1 -1
  31. package/scripts/generate-docs.js +13 -18
  32. package/scripts/plugins.txt +2 -2
  33. package/site/assets/js/main.js +5 -13
  34. package/site/content.json +6 -23
  35. package/site/index.html +29 -77
  36. package/site/ux-spec.md +7 -7
  37. package/.kiro/agents/web-session.json +0 -12
  38. package/.kiro/skills/web-auth/SKILL.md +0 -177
  39. package/.kiro/skills/web-browse/SKILL.md +0 -516
@@ -0,0 +1,242 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Binary resolver for `agent-analyzer-embed`.
5
+ *
6
+ * Mirrors the structure of `lib/binary/index.js` but for the separate
7
+ * embedder binary. Kept as its own module rather than parameterizing
8
+ * the existing resolver — the current single-binary helper is heavily
9
+ * specialized and a refactor would touch every call site for one
10
+ * use case.
11
+ *
12
+ * Both binaries share:
13
+ * - the same install dir (`~/.agent-sh/bin/`)
14
+ * - the same release-tag-aware download (latest tag with TTL cache)
15
+ * - the same platform map
16
+ *
17
+ * They differ in:
18
+ * - binary name (`agent-analyzer-embed` here)
19
+ * - GitHub release asset path uses the embed binary name
20
+ *
21
+ * @module lib/embed/binary
22
+ */
23
+
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const os = require('os');
27
+ const https = require('https');
28
+ const cp = require('child_process');
29
+
30
+ // Reuse PLATFORM_MAP from the main resolver to avoid drift if a new
31
+ // platform is added.
32
+ const mainBinary = require('../../binary');
33
+ // Reuse HTTP + archive helpers so a single bug fix to e.g. timeout
34
+ // behavior or redirect handling lands once and applies to both
35
+ // binaries (`agent-analyzer` and `agent-analyzer-embed`).
36
+ const sharedHelpers = require('../../binary/shared-helpers');
37
+
38
+ const EMBED_BINARY_NAME = 'agent-analyzer-embed';
39
+ const EMBED_GITHUB_REPO = 'agent-sh/agent-analyzer';
40
+ const LATEST_VERSION_TTL_MS = 60 * 60 * 1000;
41
+ const PLATFORM_MAP = mainBinary.PLATFORM_MAP;
42
+
43
+ function getBinaryPath() {
44
+ const ext = process.platform === 'win32' ? '.exe' : '';
45
+ return path.join(os.homedir(), '.agent-sh', 'bin', EMBED_BINARY_NAME + ext);
46
+ }
47
+
48
+ // Platform-specific ONNX Runtime dylib bundled beside the embed binary in the
49
+ // release tarball (see release.yml). The Rust binary's runtime resolver
50
+ // (resolve_and_preflight_ort) looks for exactly this name next to the
51
+ // executable. Mirrored here so the JS side can tell whether an existing
52
+ // install predates the bundling and needs a re-download.
53
+ function getBundledOrtName() {
54
+ if (process.platform === 'win32') return 'onnxruntime.dll';
55
+ if (process.platform === 'darwin') return 'libonnxruntime.dylib';
56
+ return 'libonnxruntime.so';
57
+ }
58
+
59
+ function getBundledOrtPath() {
60
+ return path.join(path.dirname(getBinaryPath()), getBundledOrtName());
61
+ }
62
+
63
+ // musl has no bundled ORT (no MS musl build; a glibc dylib cannot dlopen under
64
+ // musl). On musl we never expect a sibling lib, so its absence must not trigger
65
+ // a perpetual re-download loop.
66
+ function platformBundlesOrt() {
67
+ const key = getPlatformKey();
68
+ return !!key && !key.includes('musl');
69
+ }
70
+
71
+ function getPlatformKey() {
72
+ const key = process.platform + '-' + process.arch;
73
+ return PLATFORM_MAP[key] || null;
74
+ }
75
+
76
+ function getVersion() {
77
+ const binPath = getBinaryPath();
78
+ if (!fs.existsSync(binPath)) return null;
79
+ try {
80
+ const out = cp.execFileSync(binPath, ['--version'], {
81
+ timeout: 5000,
82
+ encoding: 'utf8',
83
+ stdio: ['pipe', 'pipe', 'pipe'],
84
+ windowsHide: true
85
+ });
86
+ const match = out.trim().match(/(\d+\.\d+\.\d+)/);
87
+ return match ? match[1] : out.trim();
88
+ } catch (e) {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ function isAvailable() {
94
+ return fs.existsSync(getBinaryPath());
95
+ }
96
+
97
+ let _latestVersionCache = null;
98
+
99
+ async function getLatestReleaseVersion() {
100
+ if (
101
+ _latestVersionCache &&
102
+ Date.now() - _latestVersionCache.fetchedAt < LATEST_VERSION_TTL_MS
103
+ ) {
104
+ return _latestVersionCache.version;
105
+ }
106
+ return new Promise(function (resolve, reject) {
107
+ const ghToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
108
+ const headers = {
109
+ 'User-Agent': 'agent-sh/embed-resolver',
110
+ 'Accept': 'application/vnd.github+json'
111
+ };
112
+ if (ghToken) headers['Authorization'] = 'Bearer ' + ghToken;
113
+
114
+ const url = 'https://api.github.com/repos/' + EMBED_GITHUB_REPO + '/releases/latest';
115
+ const fail = function (msg) {
116
+ reject(new Error(msg + ' fetching ' + url));
117
+ };
118
+ const req = https.get(url, { headers: headers, timeout: 5000 }, function (res) {
119
+ if (res.statusCode !== 200) {
120
+ res.resume();
121
+ fail('HTTP ' + res.statusCode);
122
+ return;
123
+ }
124
+ const chunks = [];
125
+ res.on('data', function (chunk) { chunks.push(chunk); });
126
+ res.on('end', function () {
127
+ try {
128
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
129
+ const tag = (body && body.tag_name) || '';
130
+ const version = tag.replace(/^v/, '');
131
+ if (/^\d+\.\d+\.\d+/.test(version)) {
132
+ _latestVersionCache = { version: version, fetchedAt: Date.now() };
133
+ resolve(version);
134
+ } else {
135
+ fail('No valid release tag');
136
+ }
137
+ } catch (e) {
138
+ fail('Failed to parse release JSON: ' + e.message);
139
+ }
140
+ });
141
+ res.on('error', function (e) { fail(e.message); });
142
+ });
143
+ req.on('error', function (e) { fail(e.message); });
144
+ req.on('timeout', function () { req.destroy(); fail('Timeout'); });
145
+ });
146
+ }
147
+
148
+ function buildDownloadUrl(ver, platformKey) {
149
+ const ext = process.platform === 'win32' ? '.zip' : '.tar.gz';
150
+ return (
151
+ 'https://github.com/' +
152
+ EMBED_GITHUB_REPO +
153
+ '/releases/download/v' +
154
+ ver +
155
+ '/' +
156
+ EMBED_BINARY_NAME +
157
+ '-' +
158
+ platformKey +
159
+ ext
160
+ );
161
+ }
162
+
163
+ // Per-call shim so the embed resolver passes its own User-Agent
164
+ // header without duplicating the rest of the HTTP plumbing.
165
+ function downloadToBuffer(url) {
166
+ return sharedHelpers.downloadToBuffer(url, { userAgent: 'agent-sh/embed-resolver' });
167
+ }
168
+
169
+ const extractTarGz = sharedHelpers.extractTarGz;
170
+ const extractZip = sharedHelpers.extractZip;
171
+
172
+ async function downloadBinary(ver) {
173
+ const platformKey = getPlatformKey();
174
+ if (!platformKey) {
175
+ throw new Error(
176
+ 'Unsupported platform: ' + process.platform + '-' + process.arch + '. ' +
177
+ 'Supported: ' + Object.keys(PLATFORM_MAP).join(', ')
178
+ );
179
+ }
180
+ const url = buildDownloadUrl(ver, platformKey);
181
+ process.stderr.write('Downloading ' + EMBED_BINARY_NAME + ' v' + ver + ' for ' + platformKey + '...\n');
182
+
183
+ const binPath = getBinaryPath();
184
+ const binDir = path.dirname(binPath);
185
+ fs.mkdirSync(binDir, { recursive: true });
186
+
187
+ let buf;
188
+ try {
189
+ buf = await downloadToBuffer(url);
190
+ } catch (err) {
191
+ throw new Error(
192
+ 'Failed to download ' + EMBED_BINARY_NAME + ':\n' +
193
+ ' URL: ' + url + '\n' +
194
+ ' Error: ' + err.message + '\n\n' +
195
+ 'To install manually:\n' +
196
+ ' 1. Download: ' + url + '\n' +
197
+ ' 2. Extract the binary to: ' + binDir + '\n' +
198
+ ' 3. Ensure it is named: ' + path.basename(binPath)
199
+ );
200
+ }
201
+
202
+ if (process.platform === 'win32') {
203
+ await extractZip(buf, binDir, path.basename(binPath));
204
+ } else {
205
+ await extractTarGz(buf, binDir);
206
+ }
207
+ if (process.platform !== 'win32') {
208
+ fs.chmodSync(binPath, 0o755);
209
+ }
210
+ return binPath;
211
+ }
212
+
213
+ async function ensureBinary(options) {
214
+ const opts = options || {};
215
+ const binPath = getBinaryPath();
216
+ if (fs.existsSync(binPath)) {
217
+ // An install from before ORT bundling has the binary but no sibling
218
+ // dylib; without it the embedder fails at runtime. Re-download once to
219
+ // pick up the bundled lib. Skip on musl (never bundled) so this can't loop.
220
+ if (platformBundlesOrt() && !fs.existsSync(getBundledOrtPath())) {
221
+ const targetVer = opts.version || (await getLatestReleaseVersion());
222
+ return downloadBinary(targetVer);
223
+ }
224
+ return binPath;
225
+ }
226
+ const targetVer = opts.version || (await getLatestReleaseVersion());
227
+ return downloadBinary(targetVer);
228
+ }
229
+
230
+ module.exports = {
231
+ EMBED_BINARY_NAME,
232
+ getBinaryPath,
233
+ getBundledOrtName,
234
+ getBundledOrtPath,
235
+ platformBundlesOrt,
236
+ getVersion,
237
+ getPlatformKey,
238
+ getLatestReleaseVersion,
239
+ isAvailable,
240
+ ensureBinary,
241
+ buildDownloadUrl
242
+ };
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Public surface for the embed module — preference cache, binary
5
+ * resolver, and high-level orchestrator (scan / update / status).
6
+ *
7
+ * Skill code should import from here, not the internal modules,
8
+ * so the wiring stays swappable.
9
+ *
10
+ * @module lib/embed
11
+ */
12
+
13
+ const preference = require('./preference');
14
+ const binary = require('./binary');
15
+ const orchestrator = require('./orchestrator');
16
+
17
+ module.exports = {
18
+ preference,
19
+ binary,
20
+ orchestrator,
21
+ // Convenience re-exports for the common cases.
22
+ isEnabled: orchestrator.isEnabled,
23
+ runScan: orchestrator.runScan,
24
+ runUpdate: orchestrator.runUpdate,
25
+ status: orchestrator.status
26
+ };
@@ -0,0 +1,239 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * High-level embed orchestration. Glues together:
5
+ *
6
+ * user preference → binary download → scan/update → set-embeddings
7
+ *
8
+ * Called from the `/repo-intel enrich` command after the existing
9
+ * weighter and summarizer Haiku agents finish; degrades to a no-op
10
+ * when the user has chosen `embedder: "none"`.
11
+ *
12
+ * Also exposes `runUpdate()` for the standalone `/repo-intel embed
13
+ * update` action group (and the `npx ... embed update` CI hook).
14
+ *
15
+ * @module lib/embed/orchestrator
16
+ */
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const cp = require('child_process');
21
+
22
+ const preference = require('./preference');
23
+ const embedBinary = require('./binary');
24
+ const mainBinary = require('../../binary');
25
+ const cache = require('../cache');
26
+
27
+ /**
28
+ * Should the orchestrator run for this repo? Returns false when the
29
+ * user has not opted in (preference unset or 'none'), which lets
30
+ * callers safely no-op without wrapping every call site in a guard.
31
+ *
32
+ * @param {string} cwd
33
+ * @returns {boolean}
34
+ */
35
+ function isEnabled(cwd) {
36
+ const pref = preference.read(cwd);
37
+ return pref.embedder === 'small' || pref.embedder === 'big';
38
+ }
39
+
40
+ /**
41
+ * Run a full scan: ensures the embed binary is downloaded, runs the
42
+ * scan subcommand, pipes the JSON document into `agent-analyzer
43
+ * repo-intel set-embeddings`. Returns a small status object so callers
44
+ * can report what happened.
45
+ *
46
+ * @param {string} cwd
47
+ * @returns {Promise<{ran: boolean, files?: number, durationMs?: number, reason?: string}>}
48
+ */
49
+ async function runScan(cwd) {
50
+ if (!isEnabled(cwd)) {
51
+ return { ran: false, reason: 'embedder preference is "none" or unset' };
52
+ }
53
+ const pref = preference.read(cwd);
54
+ const detail = preference.detailToCliArg(pref.embedderDetail || 'balanced');
55
+
56
+ const mapFile = cache.getPath(cwd);
57
+ if (!fs.existsSync(mapFile)) {
58
+ return { ran: false, reason: 'no repo-intel map found; run `/repo-intel init` first' };
59
+ }
60
+
61
+ const start = Date.now();
62
+ const embedBin = await embedBinary.ensureBinary();
63
+ const mainBin = await mainBinary.ensureBinary();
64
+
65
+ const result = await streamEmbedToSetEmbeddings(
66
+ embedBin,
67
+ ['scan', cwd, '--variant', pref.embedder, '--detail', detail],
68
+ mainBin,
69
+ mapFile
70
+ );
71
+ return Object.assign({ ran: true, durationMs: Date.now() - start }, result);
72
+ }
73
+
74
+ /**
75
+ * Run a delta update: only re-embeds files whose content hash differs
76
+ * from the existing sidecar. Falls back to a full scan when no
77
+ * sidecar exists yet.
78
+ *
79
+ * @param {string} cwd
80
+ * @returns {Promise<{ran: boolean, files?: number, durationMs?: number, reason?: string}>}
81
+ */
82
+ async function runUpdate(cwd) {
83
+ if (!isEnabled(cwd)) {
84
+ return { ran: false, reason: 'embedder preference is "none" or unset' };
85
+ }
86
+ const pref = preference.read(cwd);
87
+ const detail = preference.detailToCliArg(pref.embedderDetail || 'balanced');
88
+
89
+ const mapFile = cache.getPath(cwd);
90
+ if (!fs.existsSync(mapFile)) {
91
+ return { ran: false, reason: 'no repo-intel map; run `/repo-intel init` then `enrich`' };
92
+ }
93
+
94
+ const start = Date.now();
95
+ const embedBin = await embedBinary.ensureBinary();
96
+ const mainBin = await mainBinary.ensureBinary();
97
+
98
+ const result = await streamEmbedToSetEmbeddings(
99
+ embedBin,
100
+ ['update', cwd, '--map-file', mapFile, '--variant', pref.embedder, '--detail', detail],
101
+ mainBin,
102
+ mapFile
103
+ );
104
+ return Object.assign({ ran: true, durationMs: Date.now() - start }, result);
105
+ }
106
+
107
+ /**
108
+ * Status snapshot: which preference is set, whether the binary is
109
+ * installed, whether the bundled ONNX Runtime is present, whether the
110
+ * sidecar exists.
111
+ *
112
+ * `ortBundled` surfaces the prerequisite the embed binary needs at runtime:
113
+ * the binary loads libonnxruntime from beside itself (shipped in the release
114
+ * tarball). On a platform that bundles ORT, a present binary with a missing
115
+ * lib means an upgrade re-download is pending - ensureBinary handles it, but
116
+ * status reports it so callers can explain a one-time re-fetch.
117
+ *
118
+ * @param {string} cwd
119
+ * @returns {{enabled: boolean, embedder?: string, embedderDetail?: string, binaryInstalled: boolean, ortBundled: boolean, sidecarExists: boolean, sidecarPath?: string}}
120
+ */
121
+ function status(cwd) {
122
+ const pref = preference.read(cwd);
123
+ const mapFile = cache.getPath(cwd);
124
+ const sidecarPath = deriveSidecarPath(mapFile);
125
+ return {
126
+ enabled: isEnabled(cwd),
127
+ embedder: pref.embedder,
128
+ embedderDetail: pref.embedderDetail,
129
+ binaryInstalled: embedBinary.isAvailable(),
130
+ ortBundled: !embedBinary.platformBundlesOrt() || fs.existsSync(embedBinary.getBundledOrtPath()),
131
+ sidecarExists: fs.existsSync(sidecarPath),
132
+ sidecarPath: sidecarPath
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Stream the embed binary's stdout directly into the main binary's
138
+ * `set-embeddings --input -` stdin. The intermediate JSON document
139
+ * can run into the megabytes for big repos at high detail; piping
140
+ * keeps memory flat instead of buffering the whole document.
141
+ *
142
+ * The promise resolves with `{ files }` when both children exit cleanly;
143
+ * rejects with the failing child's exit code + captured stderr otherwise.
144
+ *
145
+ * Hardened against the failure modes a bare `pipe()` leaves open: a stream
146
+ * error (e.g. set-embeddings dies mid-write) used to leave the promise
147
+ * unsettled forever and leak the surviving process. Now any failure path
148
+ * — spawn error, stream error, or non-zero exit — kills the sibling and
149
+ * rejects once. Embed stderr is captured (not inherited) so the error
150
+ * message carries the diagnostic instead of just an exit code.
151
+ */
152
+ function streamEmbedToSetEmbeddings(embedBinPath, embedArgs, mainBinPath, mapFile) {
153
+ return new Promise(function (resolve, reject) {
154
+ const embedChild = cp.spawn(embedBinPath, embedArgs, {
155
+ stdio: ['ignore', 'pipe', 'pipe'],
156
+ windowsHide: true
157
+ });
158
+ const setChild = cp.spawn(
159
+ mainBinPath,
160
+ ['repo-intel', 'set-embeddings', '--map-file', mapFile, '--input', '-'],
161
+ { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }
162
+ );
163
+
164
+ let embedExit = null;
165
+ let setExit = null;
166
+ let settled = false;
167
+ let setStdout = '';
168
+ let embedStderr = '';
169
+ let setStderr = '';
170
+
171
+ // Single exit path. Kills the sibling on any failure so neither process
172
+ // is left running against a closed pipe (FD leak / zombie).
173
+ function done(err, value) {
174
+ if (settled) return;
175
+ settled = true;
176
+ if (err) {
177
+ try { embedChild.kill('SIGTERM'); } catch (e) { /* already gone */ }
178
+ try { setChild.kill('SIGTERM'); } catch (e) { /* already gone */ }
179
+ reject(err);
180
+ } else {
181
+ resolve(value);
182
+ }
183
+ }
184
+
185
+ function maybeFinish() {
186
+ if (settled || embedExit === null || setExit === null) return;
187
+ if (embedExit !== 0) {
188
+ return done(new Error(
189
+ embedBinary.EMBED_BINARY_NAME + ' exited ' + embedExit +
190
+ (embedStderr.trim() ? ': ' + embedStderr.trim().slice(0, 500) : '')
191
+ ));
192
+ }
193
+ if (setExit !== 0) {
194
+ return done(new Error(
195
+ 'agent-analyzer set-embeddings exited ' + setExit +
196
+ (setStderr.trim() ? ': ' + setStderr.trim().slice(0, 500) : '')
197
+ ));
198
+ }
199
+ const m = setStdout.match(/(\d+)\s+files?/);
200
+ done(null, { files: m ? parseInt(m[1], 10) : undefined });
201
+ }
202
+
203
+ // stderr piped (not inherited) so failures carry a message.
204
+ embedChild.stderr.on('data', function (d) { embedStderr += d.toString('utf8'); });
205
+ setChild.stderr.on('data', function (d) { setStderr += d.toString('utf8'); });
206
+ setChild.stdout.on('data', function (d) { setStdout += d.toString('utf8'); });
207
+
208
+ // Stream wiring with error handling on BOTH ends of the pipe — a bare
209
+ // .pipe() swallows these and hangs.
210
+ embedChild.stdout.on('error', function (e) { done(e); });
211
+ setChild.stdin.on('error', function (e) {
212
+ // EPIPE when set-embeddings has already exited is benign — its close
213
+ // handler reports the real cause. Only surface other stdin errors.
214
+ if (e && e.code !== 'EPIPE') done(e);
215
+ });
216
+ embedChild.stdout.pipe(setChild.stdin);
217
+
218
+ embedChild.on('error', function (e) { done(e); });
219
+ setChild.on('error', function (e) { done(e); });
220
+ embedChild.on('close', function (code) { embedExit = code; maybeFinish(); });
221
+ setChild.on('close', function (code) { setExit = code; maybeFinish(); });
222
+ });
223
+ }
224
+
225
+ function deriveSidecarPath(mapFile) {
226
+ if (!mapFile) return '';
227
+ const dir = path.dirname(mapFile);
228
+ const stem = path.basename(mapFile, path.extname(mapFile));
229
+ return path.join(dir, stem + '.embeddings.bin');
230
+ }
231
+
232
+ module.exports = {
233
+ isEnabled,
234
+ runScan,
235
+ runUpdate,
236
+ status,
237
+ // exported for testing the dual-process pipe in isolation
238
+ streamEmbedToSetEmbeddings
239
+ };
@@ -0,0 +1,136 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * User preference for the embedder. Persists the answer to two
5
+ * one-time prompts so subsequent enrich runs don't re-ask:
6
+ *
7
+ * embedder : "none" | "small" | "big"
8
+ * embedderDetail : "compact" | "balanced" | "maximum"
9
+ *
10
+ * Stored in `<stateDir>/sources/preference.json` alongside the existing
11
+ * task source preference (so the user has a single place to clear all
12
+ * cached choices via `/repo-intel embed reset`).
13
+ *
14
+ * @module lib/embed/preference
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const cache = require('../cache');
20
+
21
+ const VALID_EMBEDDER = ['none', 'small', 'big'];
22
+ const VALID_DETAIL = ['compact', 'balanced', 'maximum'];
23
+
24
+ // State-directory resolution delegates to `cache.getStateDirPath` so
25
+ // the embedder reads/writes the same directory the artifact loader
26
+ // uses. Reviewer-flagged split-brain risk: an inline copy here would
27
+ // drift on candidate order, env-var support, or other policy and
28
+ // silently land preferences in a different place than the map file.
29
+ function preferencePath(cwd) {
30
+ return path.join(cache.getStateDirPath(cwd), 'sources', 'preference.json');
31
+ }
32
+
33
+ /**
34
+ * Load preference from disk. Returns an empty object when absent or
35
+ * unreadable so callers can treat "unset" uniformly.
36
+ *
37
+ * @param {string} cwd
38
+ * @returns {{embedder?: string, embedderDetail?: string}}
39
+ */
40
+ function read(cwd) {
41
+ const p = preferencePath(cwd);
42
+ if (!fs.existsSync(p)) return {};
43
+ try {
44
+ const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
45
+ return raw && typeof raw === 'object' ? raw : {};
46
+ } catch (e) {
47
+ return {};
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Merge updates into the on-disk preference file. Creates the
53
+ * containing directory if needed.
54
+ *
55
+ * @param {string} cwd
56
+ * @param {Object} patch fields to set/overwrite
57
+ * @returns {Object} the merged preference
58
+ */
59
+ function update(cwd, patch) {
60
+ const current = read(cwd);
61
+ const next = Object.assign({}, current, patch || {});
62
+ const p = preferencePath(cwd);
63
+ fs.mkdirSync(path.dirname(p), { recursive: true });
64
+ fs.writeFileSync(p, JSON.stringify(next, null, 2));
65
+ return next;
66
+ }
67
+
68
+ /**
69
+ * Strip embedder fields from preference. Used by `embed reset` to
70
+ * trigger fresh prompts on the next enrich.
71
+ *
72
+ * @param {string} cwd
73
+ */
74
+ function reset(cwd) {
75
+ const current = read(cwd);
76
+ delete current.embedder;
77
+ delete current.embedderDetail;
78
+ const p = preferencePath(cwd);
79
+ fs.mkdirSync(path.dirname(p), { recursive: true });
80
+ fs.writeFileSync(p, JSON.stringify(current, null, 2));
81
+ }
82
+
83
+ /**
84
+ * Did the user already answer the embedder install prompt?
85
+ *
86
+ * @param {string} cwd
87
+ * @returns {boolean}
88
+ */
89
+ function hasEmbedderChoice(cwd) {
90
+ const pref = read(cwd);
91
+ return VALID_EMBEDDER.includes(pref.embedder);
92
+ }
93
+
94
+ /**
95
+ * Did the user already answer the detail prompt? Only meaningful when
96
+ * embedder !== 'none'.
97
+ *
98
+ * @param {string} cwd
99
+ * @returns {boolean}
100
+ */
101
+ function hasDetailChoice(cwd) {
102
+ const pref = read(cwd);
103
+ return VALID_DETAIL.includes(pref.embedderDetail);
104
+ }
105
+
106
+ /**
107
+ * Translate the user-facing detail label into the analyzer-embed CLI
108
+ * value. Centralizes the mapping so the CLI flag never appears as a
109
+ * string literal scattered across modules.
110
+ *
111
+ * @param {string} detail
112
+ * @returns {string}
113
+ */
114
+ function detailToCliArg(detail) {
115
+ switch (detail) {
116
+ case 'compact':
117
+ return 'compact';
118
+ case 'maximum':
119
+ return 'maximum';
120
+ case 'balanced':
121
+ default:
122
+ return 'balanced';
123
+ }
124
+ }
125
+
126
+ module.exports = {
127
+ read,
128
+ update,
129
+ reset,
130
+ hasEmbedderChoice,
131
+ hasDetailChoice,
132
+ detailToCliArg,
133
+ preferencePath,
134
+ VALID_EMBEDDER,
135
+ VALID_DETAIL
136
+ };