kodelyth-ecc 2.24.11 → 2.29.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.
@@ -22,6 +22,52 @@ function getVersion() {
22
22
  catch { return null; }
23
23
  }
24
24
 
25
+ // The binary's `--version` string has proven unreliable: builds have reported an
26
+ // old number (e.g. 0.8.1) while exposing much newer tools and the `--ui` (CBM
27
+ // Lens) flag. So detect what the binary can actually DO from its own help text
28
+ // rather than trusting the version. Pure parser (unit-tested) + a thin probe.
29
+ const KNOWN_CAPABILITIES = {
30
+ lens: /--ui\b/, // CBM Lens UI (v0.12+)
31
+ traces: /\bingest_traces\b/, // runtime-trace ingestion
32
+ cypher: /\bquery_graph\b/, // Cypher + complexity/hot-path metrics
33
+ smart_ci: /\bdetect_changes\b/, // Smart CI test selection
34
+ adr: /\bmanage_adr\b/, // ADR nodes
35
+ cross_repo: /cross[-_]repo/, // cross-repo intelligence
36
+ };
37
+
38
+ function parseCapabilities(helpText) {
39
+ const h = String(helpText || '');
40
+ const caps = {};
41
+ for (const [name, re] of Object.entries(KNOWN_CAPABILITIES)) caps[name] = re.test(h);
42
+ return caps;
43
+ }
44
+
45
+ // When the binary exposes clearly-newer capabilities but reports an old version,
46
+ // the version string is not trustworthy — say so rather than displaying a number
47
+ // ECC would otherwise treat as fact. Returns a note string, or null.
48
+ function versionNote(reportedVersion, caps) {
49
+ const hasNew = !!(caps && (caps.lens || caps.traces || caps.cross_repo));
50
+ const looksOld = !!(reportedVersion && /\b0\.[0-8]\./.test(reportedVersion));
51
+ if (hasNew && looksOld) {
52
+ return `binary reports "${reportedVersion}" but exposes newer capabilities — the version string is unreliable; capability detection is authoritative. Run \`kodelythecc codebase install\` to refresh.`;
53
+ }
54
+ return null;
55
+ }
56
+
57
+ // Probe the binary's help (different builds print tools under `--help` or
58
+ // `cli --help`) and parse once. Returns the detected capability map, or all-false
59
+ // if the binary is absent.
60
+ function capabilities() {
61
+ if (!isInstalled()) return parseCapabilities('');
62
+ let help = '';
63
+ for (const argv of [['--help'], ['cli', '--help']]) {
64
+ try {
65
+ help += '\n' + execFileSync(BIN, argv, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
66
+ } catch { /* try the next help surface */ }
67
+ }
68
+ return parseCapabilities(help);
69
+ }
70
+
25
71
  function install({ log = () => {} } = {}) {
26
72
  if (isInstalled()) return { installed: false, skipped: true, reason: 'already installed', version: getVersion() };
27
73
  if (os.platform() === 'win32') {
@@ -95,7 +141,15 @@ function status() {
95
141
  const home = os.homedir();
96
142
  const c = path.join(home, '.cache', 'codebase-memory-mcp');
97
143
  if (fs.existsSync(c)) cacheDir = c;
98
- return { installed: true, version, indexed_projects, cache_dir: cacheDir };
144
+ const caps = capabilities();
145
+ return {
146
+ installed: true,
147
+ version,
148
+ version_note: versionNote(version, caps),
149
+ capabilities: caps,
150
+ indexed_projects,
151
+ cache_dir: cacheDir,
152
+ };
99
153
  }
100
154
 
101
155
  // ── Structural query proxy (thin wrapper over `codebase-memory-mcp cli`) ────
@@ -140,4 +194,7 @@ function dashboardSnapshot() {
140
194
  return out;
141
195
  }
142
196
 
143
- module.exports = { isInstalled, getVersion, install, autoConfigureAgents, status, query, dashboardSnapshot };
197
+ module.exports = {
198
+ isInstalled, getVersion, install, autoConfigureAgents, status, query, dashboardSnapshot,
199
+ capabilities, parseCapabilities, versionNote,
200
+ };
@@ -4,6 +4,7 @@
4
4
  const fs = require('fs');
5
5
  const os = require('os');
6
6
  const path = require('path');
7
+ const { redact } = require('../lib/redact');
7
8
 
8
9
  const MAX_STDIN = 1024 * 1024;
9
10
  let raw = '';
@@ -20,17 +21,9 @@ const MODE_CONFIG = {
20
21
  };
21
22
 
22
23
  function sanitizeCommand(command) {
23
- return String(command || '')
24
- .replace(/\n/g, ' ')
25
- .replace(/--token[= ][^ ]*/g, '--token=<REDACTED>')
26
- .replace(/Authorization:[: ]*[^ ]*[: ]*[^ ]*/gi, 'Authorization:<REDACTED>')
27
- .replace(/\bAKIA[A-Z0-9]{16}\b/g, '<REDACTED>')
28
- .replace(/\bASIA[A-Z0-9]{16}\b/g, '<REDACTED>')
29
- .replace(/password[= ][^ ]*/gi, 'password=<REDACTED>')
30
- .replace(/\bghp_[A-Za-z0-9_]+\b/g, '<REDACTED>')
31
- .replace(/\bgho_[A-Za-z0-9_]+\b/g, '<REDACTED>')
32
- .replace(/\bghs_[A-Za-z0-9_]+\b/g, '<REDACTED>')
33
- .replace(/\bgithub_pat_[A-Za-z0-9_]+\b/g, '<REDACTED>');
24
+ // Flatten to one log line, then run the shared credential redactor (which now
25
+ // covers more token formats than this hook's old inline list did).
26
+ return redact(String(command || '').replace(/\n/g, ' '));
34
27
  }
35
28
 
36
29
  function appendLine(filePath, line) {
@@ -22,6 +22,7 @@ const {
22
22
  log
23
23
  } = require('../lib/utils');
24
24
  const { resolveProjectContext, writeSessionLease, resolveSessionId } = require('../lib/observer-sessions');
25
+ const { summarizeStructuredInstincts } = require('../memory/instinct-inject');
25
26
  const { getPackageManager, getSelectionPrompt } = require('../lib/package-manager');
26
27
  const { listAliases } = require('../lib/session-aliases');
27
28
  const { detectProjectType } = require('../lib/project-detect');
@@ -380,6 +381,30 @@ async function main() {
380
381
  additionalContextParts.push(instinctSummary);
381
382
  }
382
383
 
384
+ // Also inject from the structured instinct store (scripts/memory/instincts.js).
385
+ // It was written on every correction but never read — this activates the
386
+ // dormant self-learning loop. Fully fail-safe: never breaks session start.
387
+ const structuredInstinctSummary = summarizeStructuredInstincts(
388
+ (observerContext && observerContext.projectRoot) || process.cwd(),
389
+ { log }
390
+ );
391
+ if (structuredInstinctSummary) {
392
+ additionalContextParts.push(structuredInstinctSummary);
393
+ }
394
+
395
+ // Surface the auto-capture review queue so candidates the last session proposed
396
+ // are discoverable now, not just at the end of that session. Consent-first:
397
+ // this only points at the review command; nothing is stored without the user
398
+ // running promote-pending.
399
+ try {
400
+ const n = require('../memory/pending').countPending();
401
+ if (n > 0) {
402
+ additionalContextParts.push(
403
+ `${n} memory candidate(s) await review — list with "kodelythecc memory list-pending", confirm with "promote-pending <id|all>".`
404
+ );
405
+ }
406
+ } catch { /* never block session start */ }
407
+
383
408
  // Check for recent session files (last 7 days)
384
409
  const recentSessions = dedupeRecentSessions(sessionSearchDirs);
385
410
 
@@ -0,0 +1,58 @@
1
+ // scripts/lib/redact.js
2
+ //
3
+ // One place to scrub credentials out of text before it is stored or logged.
4
+ // Used by the memory capture path (so a leaked key is never written into
5
+ // memories.jsonl, where it would then be surfaced by recall and injected into a
6
+ // future session) and by the bash-command logger.
7
+ //
8
+ // Two rule families:
9
+ // - High-confidence provider token FORMATS, matched anywhere (prose or a
10
+ // command line) — AWS, GitHub, OpenAI/Anthropic, Stripe, Slack, Google, npm,
11
+ // JWTs, PEM private-key blocks.
12
+ // - Assignment / header forms — `--token=`, `Authorization:`, `password=`,
13
+ // `api_key=`, etc.
14
+ //
15
+ // Every pattern is linear (no nested quantifiers, no adjacent `\s*\s*`) so none
16
+ // can backtrack catastrophically on hostile input — the ReDoS lesson from the
17
+ // prompt-injection guard applies here too.
18
+ 'use strict';
19
+
20
+ const RULES = [
21
+ // ── Provider token formats (match anywhere) ──
22
+ [/-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g, '<REDACTED:private-key>'],
23
+ [/\bAKIA[0-9A-Z]{16}\b/g, '<REDACTED:aws-key>'],
24
+ [/\bASIA[0-9A-Z]{16}\b/g, '<REDACTED:aws-temp-key>'],
25
+ [/\bghp_[A-Za-z0-9]{20,}\b/g, '<REDACTED:github-token>'],
26
+ [/\bgho_[A-Za-z0-9]{20,}\b/g, '<REDACTED:github-token>'],
27
+ [/\bghs_[A-Za-z0-9]{20,}\b/g, '<REDACTED:github-token>'],
28
+ [/\bghr_[A-Za-z0-9]{20,}\b/g, '<REDACTED:github-token>'],
29
+ [/\bgithub_pat_[A-Za-z0-9_]{22,}\b/g, '<REDACTED:github-pat>'],
30
+ [/\bsk-ant-[A-Za-z0-9_-]{20,}/g, '<REDACTED:anthropic-key>'],
31
+ [/\bsk-[A-Za-z0-9_-]{20,}/g, '<REDACTED:openai-key>'],
32
+ [/\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g, '<REDACTED:stripe-key>'],
33
+ [/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, '<REDACTED:slack-token>'],
34
+ [/\bAIza[0-9A-Za-z_-]{35}\b/g, '<REDACTED:google-key>'],
35
+ [/\bnpm_[A-Za-z0-9]{36}\b/g, '<REDACTED:npm-token>'],
36
+ [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, '<REDACTED:jwt>'],
37
+ // ── Assignment / header forms ──
38
+ [/(--token[= ])\S+/g, '$1<REDACTED>'],
39
+ [/(Authorization:\s*)(?:Bearer\s+)?\S+/gi, '$1<REDACTED>'],
40
+ [/(\bpassword\s*[=:]\s*)\S+/gi, '$1<REDACTED>'],
41
+ [/(\b(?:api[_-]?key|secret|access[_-]?token|auth[_-]?token)\s*[=:]\s*)["']?[A-Za-z0-9\-_.]{8,}["']?/gi, '$1<REDACTED>'],
42
+ ];
43
+
44
+ // Replace any credentials in `text` with labelled placeholders. Non-strings pass
45
+ // through unchanged (null/undefined stay as they are).
46
+ function redact(text) {
47
+ if (typeof text !== 'string') return text;
48
+ let out = text;
49
+ for (const [re, repl] of RULES) out = out.replace(re, repl);
50
+ return out;
51
+ }
52
+
53
+ // True when redaction would change the text — i.e. a credential is present.
54
+ function hasSecret(text) {
55
+ return typeof text === 'string' && redact(text) !== text;
56
+ }
57
+
58
+ module.exports = { redact, hasSecret, RULES };
@@ -3,7 +3,6 @@
3
3
  const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
- const initSqlJs = require('sql.js');
7
6
 
8
7
  const { applyMigrations, getAppliedMigrations } = require('./migrations');
9
8
  const { createQueryApi } = require('./queries');
@@ -11,6 +10,40 @@ const { assertValidEntity, validateEntity } = require('./schema');
11
10
 
12
11
  const DEFAULT_STATE_STORE_RELATIVE_PATH = path.join('.claude', 'ecc', 'state.db');
13
12
 
13
+ // sql.js is OPTIONAL. It powers the session store, the status DB, and the
14
+ // skill-evolution tracker — not the core (memory, RTK, Terse, codebase graph),
15
+ // which stay pure-JS and need nothing. It used to be required at module load, so
16
+ // importing state-store threw "Cannot find module 'sql.js'" for every install
17
+ // that did not have it (which, since it was never a declared dependency, was all
18
+ // of them) — before any caller's try/catch could run. Load it lazily and turn a
19
+ // missing module into a clean, catchable error instead of an import-time crash.
20
+ function loadSqlJs() {
21
+ try {
22
+ return require('sql.js');
23
+ } catch (cause) {
24
+ const err = new Error(
25
+ 'SQLite support is unavailable: the optional dependency "sql.js" is not installed. ' +
26
+ 'Install it (npm i sql.js, or reinstall kodelyth-ecc) to enable the session store, ' +
27
+ 'status DB, and skill-evolution tracker. Core memory, RTK, Terse, and the codebase ' +
28
+ 'graph do not need it.'
29
+ );
30
+ err.code = 'ECC_SQLITE_UNAVAILABLE';
31
+ err.cause = cause;
32
+ throw err;
33
+ }
34
+ }
35
+
36
+ // Non-throwing probe for callers and `doctor`: is the optional SQLite layer
37
+ // usable on this install?
38
+ function isSqliteAvailable() {
39
+ try {
40
+ require.resolve('sql.js');
41
+ return true;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
14
47
  function resolveStateStorePath(options = {}) {
15
48
  if (options.dbPath) {
16
49
  if (options.dbPath === ':memory:') {
@@ -163,6 +196,7 @@ async function openDatabase(SQL, dbPath) {
163
196
 
164
197
  async function createStateStore(options = {}) {
165
198
  const dbPath = resolveStateStorePath(options);
199
+ const initSqlJs = loadSqlJs();
166
200
  const SQL = await initSqlJs();
167
201
  const db = await openDatabase(SQL, dbPath);
168
202
  const appliedMigrations = applyMigrations(db);
@@ -188,4 +222,5 @@ module.exports = {
188
222
  DEFAULT_STATE_STORE_RELATIVE_PATH,
189
223
  createStateStore,
190
224
  resolveStateStorePath,
225
+ isSqliteAvailable,
191
226
  };
@@ -2,7 +2,11 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
- const Ajv = require('ajv');
5
+ // `ajv` is loaded lazily inside getAjv(), NOT at module top. It is an OPTIONAL
6
+ // dependency (it validates the optional SQLite state-store's entities), and
7
+ // requiring it here eagerly made importing state-store throw "Cannot find module
8
+ // 'ajv'" on any install without it — which is every default install, and which
9
+ // broke the pre-publish test step in CI. Same bug class as the sql.js one.
6
10
 
7
11
  const SCHEMA_PATH = path.join(__dirname, '..', '..', '..', 'schemas', 'state-store.schema.json');
8
12
 
@@ -33,6 +37,19 @@ function getAjv() {
33
37
  return cachedAjv;
34
38
  }
35
39
 
40
+ let Ajv;
41
+ try {
42
+ Ajv = require('ajv');
43
+ } catch (cause) {
44
+ const err = new Error(
45
+ 'state-store schema validation needs the optional dependency "ajv", which is not installed. ' +
46
+ 'Install it (or reinstall kodelyth-ecc) to enable session-store / status-DB validation.'
47
+ );
48
+ err.code = 'ECC_AJV_UNAVAILABLE';
49
+ err.cause = cause;
50
+ throw err;
51
+ }
52
+
36
53
  cachedAjv = new Ajv({
37
54
  allErrors: true,
38
55
  strict: false,
@@ -25,6 +25,108 @@ function claudeDesktopConfigPath() {
25
25
  return path.join(home, '.config', 'Claude', 'claude_desktop_config.json');
26
26
  }
27
27
 
28
+ // MCP config paths for the non-Claude IDEs ECC can auto-wire via JSON. Every
29
+ // target below uses the SAME `{ mcpServers: { <name>: {...} } }` shape as Claude
30
+ // Code (Kiro included — it is MCP-native), so the hardened readConfig +
31
+ // registerInFile merge works unchanged; only the file location differs. Codex is
32
+ // TOML and handled separately (registerCodexToml).
33
+ //
34
+ // Still manual (fragile, editor-version-specific path): Cline / Roo Code store
35
+ // MCP settings in VS Code globalStorage, so ECC does not guess that path.
36
+ function mcpPathsForTarget(target, cwd = process.cwd()) {
37
+ const home = os.homedir();
38
+ switch (target) {
39
+ case 'claude-code':
40
+ case 'claude-home':
41
+ return [claudeCodeConfigPath()];
42
+ case 'cursor':
43
+ case 'cursor-project':
44
+ return [path.join(cwd, '.cursor', 'mcp.json')];
45
+ case 'windsurf':
46
+ case 'windsurf-home':
47
+ case 'windsurf-project':
48
+ return [path.join(home, '.codeium', 'windsurf', 'mcp_config.json')];
49
+ case 'gemini-cli':
50
+ case 'gemini-home':
51
+ case 'gemini-project':
52
+ return [path.join(home, '.gemini', 'settings.json')];
53
+ case 'kiro':
54
+ case 'kiro-project':
55
+ return [path.join(cwd, '.kiro', 'settings', 'mcp.json')];
56
+ case 'kiro-home':
57
+ return [path.join(home, '.kiro', 'settings', 'mcp.json')];
58
+ default:
59
+ return [];
60
+ }
61
+ }
62
+
63
+ function codexConfigPath() {
64
+ return path.join(os.homedir(), '.codex', 'config.toml');
65
+ }
66
+
67
+ // Codex CLI stores MCP servers in TOML as [mcp_servers.<name>] tables. Without
68
+ // pulling in a TOML parser, add our table only when it is absent (idempotent),
69
+ // and otherwise leave the file exactly as it is. Appending a table is
70
+ // order-independent in TOML, so this never clobbers existing servers or settings.
71
+ function registerCodexToml(file, { dryRun = false } = {}) {
72
+ let raw = '';
73
+ try {
74
+ raw = fs.readFileSync(file, 'utf8');
75
+ } catch (err) {
76
+ if (err.code !== 'ENOENT') {
77
+ return { file, action: 'error', error: `cannot read ${file}: ${err.message}` };
78
+ }
79
+ }
80
+ if (/\[mcp_servers\.(?:"?kodelyth-ecc"?)\]/.test(raw)) {
81
+ return { file, action: 'unchanged' };
82
+ }
83
+ const table = `\n[mcp_servers."${SERVER_NAME}"]\ncommand = "kodelythecc"\nargs = ["mcp"]\n`;
84
+ const next = raw.length && !raw.endsWith('\n') ? `${raw}\n${table}` : `${raw}${table}`;
85
+ if (!dryRun) {
86
+ fs.mkdirSync(path.dirname(file), { recursive: true });
87
+ safeFs.replaceFilePreservingMode(file, next);
88
+ }
89
+ return { file, action: 'added' };
90
+ }
91
+
92
+ // Returns the list of targets this module can auto-wire (for help text / docs).
93
+ function supportedMcpTargets() {
94
+ return ['claude-code', 'cursor', 'windsurf', 'gemini-cli', 'kiro', 'codex'];
95
+ }
96
+
97
+ // Register ECC's MCP server for one install target. JSON targets reuse the same
98
+ // merge that protects ~/.claude.json (absent → created, present → merged without
99
+ // clobbering, unparseable → refused); Codex uses the TOML writer above. Never
100
+ // throws — a bad config for one IDE must not fail an install.
101
+ function registerForTarget(target, { cwd = process.cwd(), log = () => {}, dryRun = false } = {}) {
102
+ if (target === 'codex' || target === 'codex-home') {
103
+ try {
104
+ const r = registerCodexToml(codexConfigPath(), { dryRun });
105
+ log(` ${r.action}: ${r.file}`);
106
+ return [{ target, ...r }];
107
+ } catch (e) {
108
+ log(` skipped codex: ${e.message}`);
109
+ return [{ target, file: codexConfigPath(), action: 'error', error: e.message }];
110
+ }
111
+ }
112
+ const paths = mcpPathsForTarget(target, cwd);
113
+ if (paths.length === 0) {
114
+ return [{ target, action: 'unsupported', reason: `no known MCP config path for "${target}"` }];
115
+ }
116
+ const results = [];
117
+ for (const p of paths) {
118
+ try {
119
+ const r = registerInFile(p, { dryRun });
120
+ log(` ${r.action}: ${r.file}`);
121
+ results.push({ target, ...r });
122
+ } catch (e) {
123
+ log(` skipped ${p}: ${e.message}`);
124
+ results.push({ target, file: p, action: 'error', error: e.message });
125
+ }
126
+ }
127
+ return results;
128
+ }
129
+
28
130
  // Reading this file has THREE outcomes, and collapsing them is destructive.
29
131
  //
30
132
  // readJson used to return null for every failure, and the caller did
@@ -145,9 +247,19 @@ function unregisterAll({ log = () => {} } = {}) {
145
247
  function statusAll() {
146
248
  const rows = [];
147
249
  for (const p of [claudeCodeConfigPath(), claudeDesktopConfigPath()]) {
148
- const cfg = readJson(p);
149
- const present = !!(cfg && cfg.mcpServers && cfg.mcpServers[SERVER_NAME]);
150
- rows.push({ file: p, present, exists: !!cfg });
250
+ // Was `readJson(p)` — a function that no longer exists after the
251
+ // absent/present/unreadable refactor, so statusAll() threw
252
+ // "readJson is not defined" and took `kodelythecc doctor` and the `mcp`
253
+ // status command down with it. Use the real reader and its tri-state result.
254
+ const cfg = readConfig(p);
255
+ const data = (cfg && cfg.data) || {};
256
+ const present = !!(data.mcpServers && data.mcpServers[SERVER_NAME]);
257
+ rows.push({
258
+ file: p,
259
+ present,
260
+ exists: cfg.state !== 'absent',
261
+ readable: cfg.state !== 'unreadable',
262
+ });
151
263
  }
152
264
  return rows;
153
265
  }
@@ -157,6 +269,11 @@ module.exports = {
157
269
  serverEntry,
158
270
  claudeCodeConfigPath,
159
271
  claudeDesktopConfigPath,
272
+ mcpPathsForTarget,
273
+ supportedMcpTargets,
274
+ registerForTarget,
275
+ registerCodexToml,
276
+ codexConfigPath,
160
277
  registerAll,
161
278
  unregisterAll,
162
279
  statusAll,
@@ -19,6 +19,7 @@ const path = require('path');
19
19
  const store = require('./store');
20
20
  const { buildContextBlock } = require('./inject');
21
21
  const { extractCandidates } = require('./extract');
22
+ const pending = require('./pending');
22
23
 
23
24
  function parseArgs(argv) {
24
25
  const args = argv.slice(2);
@@ -190,6 +191,40 @@ function main() {
190
191
  return;
191
192
  }
192
193
 
194
+ // ── Auto-capture review queue ────────────────────────────────────────────
195
+ // The Stop hook auto-extracts candidates from each session into the queue;
196
+ // these three commands are the consent gate that turns them into memories.
197
+ case 'list-pending':
198
+ case 'review-pending': {
199
+ const items = pending.listPending();
200
+ if (items.length === 0) { console.log('No memory candidates pending review.'); return; }
201
+ console.log(`${items.length} candidate(s) pending review:\n`);
202
+ items.forEach((c) => {
203
+ console.log(`[${c.id}] (score ${c.score ?? '-'}) ${c.problem}`);
204
+ console.log(` tags: ${(c.tags || []).join(', ') || '-'}`);
205
+ console.log(` approach: ${String(c.approach).slice(0, 200)}`);
206
+ console.log('');
207
+ });
208
+ console.log('Confirm with: memory promote-pending <id|all> · drop with: memory discard-pending <id|all>');
209
+ return;
210
+ }
211
+
212
+ case 'promote-pending': {
213
+ const sel = positional[0] || 'all';
214
+ const stored = pending.promotePending(sel === 'all' ? 'all' : sel);
215
+ if (stored.length === 0) { console.log('Nothing promoted (no matching pending candidate).'); return; }
216
+ console.log(`Stored ${stored.length} memor${stored.length === 1 ? 'y' : 'ies'}:`);
217
+ stored.forEach(m => console.log(` ${m.id} ${m.problem}`));
218
+ return;
219
+ }
220
+
221
+ case 'discard-pending': {
222
+ const sel = positional[0] || 'all';
223
+ const n = pending.discardPending(sel === 'all' ? 'all' : sel);
224
+ console.log(`Discarded ${n} pending candidate(s).`);
225
+ return;
226
+ }
227
+
193
228
  default:
194
229
  console.error(`Unknown command: ${cmd}`);
195
230
  help();
@@ -0,0 +1,44 @@
1
+ // scripts/memory/embedding-providers/local-transformers.js
2
+ //
3
+ // Local, on-device embedding provider. Runs a small sentence-embedding model
4
+ // through the OPTIONAL `@xenova/transformers` dependency (WASM/ONNX — no native
5
+ // build). After the model is cached once, inference is fully on-device, so the
6
+ // "100% local, zero-network" property holds at recall time; only the first-ever
7
+ // load fetches the model.
8
+ //
9
+ // This module is required ONLY when the user has opted into embeddings
10
+ // (KODELYTH_MEMORY_EMBEDDINGS=on). If the optional dependency is not installed,
11
+ // the require throws and embeddings.getProvider() catches it and falls back to
12
+ // BM25 — so a default install never needs this and never loads it.
13
+ //
14
+ // Not exercised by the CI suite (it needs the heavy optional dependency and a
15
+ // model download); the fusion/gating logic it feeds IS covered, via a stub
16
+ // provider, in tests/memory/embeddings.test.js.
17
+ 'use strict';
18
+
19
+ const MODEL = process.env.KODELYTH_EMBEDDING_MODEL || 'Xenova/all-MiniLM-L6-v2';
20
+
21
+ let _pipelinePromise = null;
22
+
23
+ async function createProvider() {
24
+ // require is inside the function so a missing optional dependency surfaces to
25
+ // the caller (embeddings.getProvider) as a catchable error, not an import-time
26
+ // crash of the memory module.
27
+ const { pipeline } = require('@xenova/transformers');
28
+ if (!_pipelinePromise) {
29
+ _pipelinePromise = pipeline('feature-extraction', MODEL);
30
+ }
31
+ const extractor = await _pipelinePromise;
32
+ return {
33
+ model: MODEL,
34
+ async embed(text) {
35
+ if (!text) return null;
36
+ // Mean-pooled, L2-normalized sentence embedding — the standard recipe for
37
+ // all-MiniLM-style models. output.data is a Float32Array.
38
+ const output = await extractor(String(text), { pooling: 'mean', normalize: true });
39
+ return Array.from(output.data);
40
+ },
41
+ };
42
+ }
43
+
44
+ module.exports = { createProvider, MODEL };
@@ -0,0 +1,98 @@
1
+ // scripts/memory/embeddings.js
2
+ //
3
+ // OPTIONAL, OFF BY DEFAULT semantic layer for memory recall. The default recall
4
+ // path stays pure BM25 — sync, zero-dependency, zero-network — exactly as
5
+ // advertised. This module only does anything when a user opts in
6
+ // (KODELYTH_MEMORY_EMBEDDINGS=on) AND a local embedding provider is actually
7
+ // available. If either is false, every function here degrades to "no semantics",
8
+ // and recall falls back to BM25 unchanged.
9
+ //
10
+ // The provider is pluggable: the real one (scripts/memory/embedding-providers/
11
+ // local-transformers.js) runs a local model via the optional `@xenova/
12
+ // transformers` dependency, so inference is on-device. Tests inject a
13
+ // deterministic stub instead, so the fusion/gating logic is verified without the
14
+ // heavy dependency or a model download.
15
+ 'use strict';
16
+
17
+ // Test seam: when set (including to null), overrides provider resolution.
18
+ let _providerOverride;
19
+ let _hasOverride = false;
20
+
21
+ function _setProviderForTest(provider) { _providerOverride = provider; _hasOverride = true; }
22
+ function _clearProviderForTest() { _providerOverride = undefined; _hasOverride = false; }
23
+
24
+ // Off unless explicitly enabled. Keeps the default session zero-network/zero-dep.
25
+ function isEnabled() {
26
+ const v = String(process.env.KODELYTH_MEMORY_EMBEDDINGS || '').trim().toLowerCase();
27
+ return v === '1' || v === 'on' || v === 'true' || v === 'yes';
28
+ }
29
+
30
+ // Resolve the embedding provider, or null. Null means "no semantics" — a
31
+ // disabled flag, a missing optional dependency, or a load failure all collapse
32
+ // to the same safe BM25-only behaviour.
33
+ async function getProvider() {
34
+ if (_hasOverride) return _providerOverride;
35
+ if (!isEnabled()) return null;
36
+ try {
37
+ const { createProvider } = require('./embedding-providers/local-transformers');
38
+ return await createProvider();
39
+ } catch {
40
+ return null; // optional dep not installed, or model load failed
41
+ }
42
+ }
43
+
44
+ // Cosine similarity of two equal-length numeric vectors. Returns 0 for missing,
45
+ // mismatched, or zero-magnitude vectors rather than NaN, so it is always safe to
46
+ // sort by.
47
+ function cosineSim(a, b) {
48
+ if (!a || !b || a.length === 0 || a.length !== b.length) return 0;
49
+ let dot = 0, na = 0, nb = 0;
50
+ for (let i = 0; i < a.length; i++) {
51
+ dot += a[i] * b[i];
52
+ na += a[i] * a[i];
53
+ nb += b[i] * b[i];
54
+ }
55
+ if (na === 0 || nb === 0) return 0;
56
+ return dot / (Math.sqrt(na) * Math.sqrt(nb));
57
+ }
58
+
59
+ // Fuse BM25 relevance with semantic similarity and re-rank. Pure function.
60
+ //
61
+ // results: BM25 results, each { id, score, ... }, already relevance-ranked
62
+ // queryVec: the query embedding (or null → returns results unchanged)
63
+ // vecById: { [id]: vector } for whichever results have a stored embedding
64
+ // weight: semantic weight in [0,1] (default 0.5). final =
65
+ // weight*semantic + (1-weight)*bm25norm, with BM25 min-max normalized
66
+ // across this result set. A result with no stored vector keeps its
67
+ // BM25-normalized score (semantic contribution 0), so missing
68
+ // embeddings never push a relevant memory down unfairly.
69
+ function fuseHybrid(results, { queryVec, vecById = {}, weight = 0.5 } = {}) {
70
+ if (!Array.isArray(results) || results.length === 0) return results || [];
71
+ if (!queryVec) return results;
72
+
73
+ const scores = results.map(r => r.score || 0);
74
+ const min = Math.min(...scores);
75
+ const max = Math.max(...scores);
76
+ const span = max - min;
77
+ const norm = (s) => (span === 0 ? 1 : (s - min) / span);
78
+
79
+ const w = Math.min(1, Math.max(0, weight));
80
+ return results
81
+ .map(r => {
82
+ const vec = vecById[r.id];
83
+ const bm25n = norm(r.score || 0);
84
+ const sem = vec ? Math.max(0, cosineSim(queryVec, vec)) : bm25n;
85
+ const fused = w * sem + (1 - w) * bm25n;
86
+ return { ...r, _bm25norm: bm25n, _semantic: vec ? sem : null, _hybrid: fused };
87
+ })
88
+ .sort((a, b) => b._hybrid - a._hybrid);
89
+ }
90
+
91
+ module.exports = {
92
+ isEnabled,
93
+ getProvider,
94
+ cosineSim,
95
+ fuseHybrid,
96
+ _setProviderForTest,
97
+ _clearProviderForTest,
98
+ };