kodelyth-ecc 2.25.0 → 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.
@@ -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,17 +25,14 @@ 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. Every target below
29
- // uses the SAME `{ mcpServers: { <name>: { command, args, env } } }` shape as
30
- // Claude Code, so the hardened readConfig + registerInFile merge works for all of
31
- // them unchanged — only the file location differs. That is why memory and the
32
- // whole MCP tool surface, previously Claude-only, reach these IDEs out of the
33
- // box once this runs.
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).
34
33
  //
35
- // Deliberately excluded (different format or fragile location — handled manually
36
- // for now, tracked as a follow-up):
37
- // - Codex CLI → ~/.codex/config.toml (TOML, not JSON)
38
- // - Cline / Roo → VS Code globalStorage (editor-version-specific path)
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.
39
36
  function mcpPathsForTarget(target, cwd = process.cwd()) {
40
37
  const home = os.homedir();
41
38
  switch (target) {
@@ -53,24 +50,68 @@ function mcpPathsForTarget(target, cwd = process.cwd()) {
53
50
  case 'gemini-home':
54
51
  case 'gemini-project':
55
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')];
56
58
  default:
57
59
  return [];
58
60
  }
59
61
  }
60
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
+
61
92
  // Returns the list of targets this module can auto-wire (for help text / docs).
62
93
  function supportedMcpTargets() {
63
- return ['claude-code', 'cursor', 'windsurf', 'gemini-cli'];
94
+ return ['claude-code', 'cursor', 'windsurf', 'gemini-cli', 'kiro', 'codex'];
64
95
  }
65
96
 
66
- // Register ECC's MCP server for one install target. Reuses the same merge that
67
- // protects ~/.claude.json: absent files are created, present files are merged
68
- // without clobbering other servers, and an unparseable file is refused, never
69
- // overwritten. Never throws — a bad config for one IDE must not fail an install.
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.
70
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
+ }
71
112
  const paths = mcpPathsForTarget(target, cwd);
72
113
  if (paths.length === 0) {
73
- return [{ target, action: 'unsupported', reason: `no known JSON MCP config path for "${target}"` }];
114
+ return [{ target, action: 'unsupported', reason: `no known MCP config path for "${target}"` }];
74
115
  }
75
116
  const results = [];
76
117
  for (const p of paths) {
@@ -231,6 +272,8 @@ module.exports = {
231
272
  mcpPathsForTarget,
232
273
  supportedMcpTargets,
233
274
  registerForTarget,
275
+ registerCodexToml,
276
+ codexConfigPath,
234
277
  registerAll,
235
278
  unregisterAll,
236
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
+ };
@@ -0,0 +1,125 @@
1
+ // scripts/memory/pending.js
2
+ //
3
+ // The consent-respecting half of auto-capture. The Stop hook
4
+ // (hooks/memory/capture-stop.js) already AUTO-EXTRACTS memory candidates from a
5
+ // finished session and queues them to pending-review.jsonl — it never stores
6
+ // them. But nothing ever read that queue or promoted it, so the candidates piled
7
+ // up with no way to confirm them (the `/memory review-pending` the hook points at
8
+ // did not exist). This module closes the loop while honouring the memory
9
+ // protocol's rule: "never capture without explicit confirmation."
10
+ //
11
+ // listPending() — review what the session proposed
12
+ // promotePending(id) — the confirmation gate: store it (capture() redacts)
13
+ // discardPending(id) — throw it away
14
+ //
15
+ // Candidates are auto-collected; storing them is one explicit command. Nothing
16
+ // enters the recall store silently.
17
+ 'use strict';
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const crypto = require('crypto');
22
+ const safeFs = require('../lib/safe-fs.js');
23
+ const store = require('./store.js');
24
+
25
+ function pendingFile() {
26
+ return path.join(store.PATHS.dir, 'pending-review.jsonl');
27
+ }
28
+
29
+ // Stable id from content, so the same proposal queued across several sessions
30
+ // folds to one entry and the user can reference it to confirm/discard.
31
+ function candidateId(c) {
32
+ return crypto.createHash('sha256')
33
+ .update(`${(c && c.problem) || ''}::${(c && c.approach) || ''}`)
34
+ .digest('hex')
35
+ .slice(0, 8);
36
+ }
37
+
38
+ function readQueue() {
39
+ const f = pendingFile();
40
+ if (!fs.existsSync(f)) return [];
41
+ const out = [];
42
+ for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
43
+ if (!line) continue;
44
+ let row; try { row = JSON.parse(line); } catch { continue; }
45
+ if (!row || !row.problem || !row.approach) continue;
46
+ out.push(row);
47
+ }
48
+ return out;
49
+ }
50
+
51
+ function writeQueue(rows) {
52
+ const f = pendingFile();
53
+ if (rows.length === 0) {
54
+ try { fs.unlinkSync(f); } catch { /* already gone */ }
55
+ return;
56
+ }
57
+ safeFs.replaceFilePreservingMode(f, rows.map(r => JSON.stringify(r)).join('\n') + '\n');
58
+ }
59
+
60
+ // Deduped view of the queue, newest first, each with its stable id.
61
+ function listPending() {
62
+ const byId = new Map();
63
+ for (const c of readQueue()) {
64
+ const id = candidateId(c);
65
+ const prior = byId.get(id);
66
+ if (!prior || (c.queued_at || '') > (prior.queued_at || '')) {
67
+ byId.set(id, { ...c, id });
68
+ }
69
+ }
70
+ return [...byId.values()].sort((a, b) => (b.queued_at || '').localeCompare(a.queued_at || ''));
71
+ }
72
+
73
+ function countPending() {
74
+ return listPending().length;
75
+ }
76
+
77
+ function selectIds(selector) {
78
+ if (selector === 'all' || selector == null) return new Set(listPending().map(c => c.id));
79
+ const arr = Array.isArray(selector) ? selector : [selector];
80
+ return new Set(arr.map(String));
81
+ }
82
+
83
+ // The confirmation gate. Promotes the selected candidates into the recall store
84
+ // via capture() — which redacts secrets — then removes them from the queue.
85
+ // Returns the stored memories.
86
+ function promotePending(selector = 'all') {
87
+ const ids = selectIds(selector);
88
+ const promotedContentIds = new Set();
89
+ const stored = [];
90
+ for (const c of listPending()) {
91
+ if (!ids.has(c.id)) continue;
92
+ stored.push(store.capture({
93
+ problem: c.problem,
94
+ approach: c.approach,
95
+ tags: c.tags || [],
96
+ project: c.project_path || null,
97
+ language: c.language || null,
98
+ files: c.files || [],
99
+ source: 'auto-confirmed',
100
+ }));
101
+ promotedContentIds.add(c.id); // c.id === candidateId(c)
102
+ }
103
+ const remaining = readQueue().filter(c => !promotedContentIds.has(candidateId(c)));
104
+ writeQueue(remaining);
105
+ return stored;
106
+ }
107
+
108
+ // Remove candidates from the queue without storing them. Returns the count removed.
109
+ function discardPending(selector = 'all') {
110
+ const ids = selectIds(selector);
111
+ const discardContentIds = new Set(listPending().filter(c => ids.has(c.id)).map(c => c.id));
112
+ const before = readQueue();
113
+ const remaining = before.filter(c => !discardContentIds.has(candidateId(c)));
114
+ writeQueue(remaining);
115
+ return before.length - remaining.length;
116
+ }
117
+
118
+ module.exports = {
119
+ pendingFile,
120
+ candidateId,
121
+ listPending,
122
+ countPending,
123
+ promotePending,
124
+ discardPending,
125
+ };
@@ -0,0 +1,93 @@
1
+ // scripts/memory/project-id.js
2
+ //
3
+ // One project-identity resolver for the memory subsystem. Memory used to key a
4
+ // project by sha256(absolute path), while the observer keyed it by git remote —
5
+ // so the moment you cloned, moved, or renamed a repo, its absolute path changed
6
+ // and all of its memories orphaned (they were filed under the old path hash).
7
+ //
8
+ // This resolver is git-remote-first with a path fallback, and NORMALIZES the
9
+ // remote so the same repo always hashes the same way regardless of protocol,
10
+ // host form, .git suffix, embedded credentials, or trailing slash:
11
+ //
12
+ // git@github.com:owner/repo.git ─┐
13
+ // https://github.com/owner/repo/ ├─→ remote:github.com/owner/repo → one id
14
+ // ssh://git@github.com/owner/repo ─┘
15
+ //
16
+ // Backward compatibility is handled by read-both: new memories are written under
17
+ // the git-remote id, but recall also matches the LEGACY path id, so memories
18
+ // captured before this change are never lost. Zero deps, local only.
19
+ 'use strict';
20
+
21
+ const crypto = require('crypto');
22
+ const { spawnSync } = require('child_process');
23
+
24
+ function sha12(s) {
25
+ return crypto.createHash('sha256').update(String(s)).digest('hex').slice(0, 12);
26
+ }
27
+
28
+ function runGit(args, cwd) {
29
+ try {
30
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
31
+ if (r.status !== 0) return '';
32
+ return (r.stdout || '').trim();
33
+ } catch {
34
+ return '';
35
+ }
36
+ }
37
+
38
+ // Strip `://user:pass@` so a credential-bearing remote and a clean one map to the
39
+ // same project (and so a secret never ends up in a hash input we might log).
40
+ function stripRemoteCredentials(url) {
41
+ if (!url) return '';
42
+ return String(url).replace(/:\/\/[^@]+@/, '://');
43
+ }
44
+
45
+ // Canonical form of a git remote URL — see the header for the shapes this folds
46
+ // together. Returns '' for empty input.
47
+ function normalizeRemote(url) {
48
+ let u = stripRemoteCredentials(url).trim();
49
+ if (!u) return '';
50
+ u = u.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ''); // strip scheme://
51
+ u = u.replace(/^git@/i, ''); // strip scp-style user
52
+ u = u.replace(/^([^/:]+):(?!\d)/, '$1/'); // host:owner/repo → host/owner/repo (scp form, not host:port)
53
+ u = u.replace(/\.git$/i, ''); // drop a trailing .git
54
+ u = u.replace(/\/+$/, ''); // drop trailing slashes
55
+ return u.toLowerCase();
56
+ }
57
+
58
+ // The legacy, path-only id the memory store used before this change. Kept so
59
+ // memories captured under the old scheme are still found (read-both).
60
+ function legacyPathId(projectRoot) {
61
+ return sha12(projectRoot);
62
+ }
63
+
64
+ // The canonical id for a project: git-remote-first (stable across clone/move),
65
+ // path fallback (local-only repos, non-git directories).
66
+ function resolveProjectId(projectRoot, { cwd } = {}) {
67
+ if (!projectRoot) return 'global';
68
+ const dir = cwd || projectRoot;
69
+ const remote = normalizeRemote(runGit(['remote', 'get-url', 'origin'], dir));
70
+ if (remote) return sha12('remote:' + remote);
71
+ return legacyPathId(projectRoot);
72
+ }
73
+
74
+ // Every id that should match this project when recalling: the canonical id AND
75
+ // the legacy path id, de-duped. New memories are written under the first; old
76
+ // ones are still found via the second. (When the repo has no remote the two are
77
+ // identical and this collapses to one.)
78
+ function matchingProjectIds(projectRoot, opts = {}) {
79
+ if (!projectRoot) return [];
80
+ const ids = new Set();
81
+ ids.add(resolveProjectId(projectRoot, opts));
82
+ ids.add(legacyPathId(projectRoot));
83
+ return [...ids];
84
+ }
85
+
86
+ module.exports = {
87
+ sha12,
88
+ stripRemoteCredentials,
89
+ normalizeRemote,
90
+ legacyPathId,
91
+ resolveProjectId,
92
+ matchingProjectIds,
93
+ };