kodelyth-ecc 2.25.0 → 2.30.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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +189 -0
- package/CLAUDE.md +7 -7
- package/GITHUB_SETUP.md +3 -3
- package/README.md +22 -22
- package/VERSION +1 -1
- package/agents/super-agent.md +102 -0
- package/bin/kodelyth-ecc.js +54 -47
- package/bundles/enterprise.md +1 -1
- package/bundles/indie-hacker.md +1 -1
- package/commands/super-agent.md +47 -0
- package/hooks/memory/capture-stop.js +1 -1
- package/install.ps1 +14 -3
- package/install.sh +17 -4
- package/package.json +6 -3
- package/rules/common/agent-intent-routing.md +2 -1
- package/rules/common/agents.md +1 -1
- package/rules/common/self-improvement-workflow.md +1 -1
- package/scripts/cli/menu.js +1 -1
- package/scripts/hooks/post-bash-command-log.js +4 -11
- package/scripts/hooks/session-start.js +13 -0
- package/scripts/lib/redact.js +58 -0
- package/scripts/lib/state-store/index.js +36 -1
- package/scripts/lib/state-store/schema.js +18 -1
- package/scripts/lib/target-paths.js +62 -0
- package/scripts/mcp/register-self.js +59 -16
- package/scripts/memory/cli.js +35 -0
- package/scripts/memory/embedding-providers/local-transformers.js +44 -0
- package/scripts/memory/embeddings.js +98 -0
- package/scripts/memory/pending.js +125 -0
- package/scripts/memory/project-id.js +93 -0
- package/scripts/memory/store.js +123 -13
- package/scripts/super-agent/cli.js +135 -0
- package/scripts/super-agent/dag.js +123 -0
- package/scripts/super-agent/integrate.js +91 -0
- package/scripts/super-agent/orchestrator.js +175 -0
- package/scripts/super-agent/state.js +136 -0
- package/wiki/Agent-Reference.md +1 -1
- package/wiki/FAQ.md +5 -5
- package/wiki/Home.md +8 -8
- package/wiki/Installation-Guide.md +1 -1
- package/wiki/Platform-Support.md +8 -8
- package/wiki/Skill-Reference.md +4 -4
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// scripts/lib/target-paths.js
|
|
2
|
+
//
|
|
3
|
+
// Single source of truth for where each IDE target reads skills and commands
|
|
4
|
+
// from. Mirrors the per-target destinations in install.sh, and is used by the
|
|
5
|
+
// `terse` install/enable path in bin/kodelyth-ecc.js so the dormant /terse
|
|
6
|
+
// skill + commands land in the same place the main installer uses.
|
|
7
|
+
//
|
|
8
|
+
// A target with no skill/command directory returns null on purpose:
|
|
9
|
+
// - cline reads only .clinerules/ markdown — no skill or command system.
|
|
10
|
+
// - kiro is MCP-native: the full catalog (terse included) is served by the
|
|
11
|
+
// ECC MCP server wired into .kiro/settings/mcp.json, not copied files.
|
|
12
|
+
// Callers already guard on null, so those targets are skipped cleanly.
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const os = require('node:os');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
|
|
18
|
+
// Normalise alias → canonical target (matches scripts/rtk/index.js normalizeTarget).
|
|
19
|
+
function canonTarget(target) {
|
|
20
|
+
if (target === 'claude-code') return 'claude-home';
|
|
21
|
+
if (target === 'gemini-cli') return 'gemini-home';
|
|
22
|
+
if (target === 'cursor') return 'cursor-project';
|
|
23
|
+
return target;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getTargetSkillsDir(target, { home = os.homedir(), cwd = process.cwd() } = {}) {
|
|
27
|
+
switch (canonTarget(target)) {
|
|
28
|
+
case 'claude-home': return path.join(home, '.claude', 'skills');
|
|
29
|
+
case 'cursor-project': return path.join(home, '.cursor', 'skills');
|
|
30
|
+
case 'windsurf-home':
|
|
31
|
+
case 'windsurf-project': return path.join(home, '.codeium', 'windsurf', 'skills');
|
|
32
|
+
case 'antigravity': return path.join(home, '.antigravity', 'skills');
|
|
33
|
+
case 'codex-home': return path.join(home, '.codex', 'skills');
|
|
34
|
+
case 'gemini-home': return path.join(home, '.gemini', 'skills');
|
|
35
|
+
case 'gemini-project': return path.join(cwd, '.gemini', 'skills');
|
|
36
|
+
case 'opencode': return path.join(home, '.config', 'opencode', 'skills');
|
|
37
|
+
case 'roocode': return path.join(cwd, '.roo', 'skills');
|
|
38
|
+
case 'aider': return path.join(cwd, '.aider-ecc', 'skills');
|
|
39
|
+
case 'kimi': return path.join(cwd, '.kimi', 'skills');
|
|
40
|
+
default: return null; // cline, kiro — see header note.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getTargetCommandsDir(target, { home = os.homedir(), cwd = process.cwd() } = {}) {
|
|
45
|
+
switch (canonTarget(target)) {
|
|
46
|
+
case 'claude-home': return path.join(home, '.claude', 'commands');
|
|
47
|
+
case 'cursor-project': return path.join(home, '.cursor', 'commands');
|
|
48
|
+
case 'windsurf-home':
|
|
49
|
+
case 'windsurf-project': return path.join(home, '.codeium', 'windsurf', 'commands');
|
|
50
|
+
case 'antigravity': return path.join(home, '.antigravity', 'commands');
|
|
51
|
+
case 'codex-home': return path.join(home, '.codex', 'commands');
|
|
52
|
+
case 'gemini-home': return path.join(home, '.gemini', 'commands');
|
|
53
|
+
case 'gemini-project': return path.join(cwd, '.gemini', 'commands');
|
|
54
|
+
case 'opencode': return path.join(home, '.config', 'opencode', 'commands');
|
|
55
|
+
case 'roocode': return path.join(cwd, '.roo', 'commands');
|
|
56
|
+
case 'aider': return path.join(cwd, '.aider-ecc', 'commands');
|
|
57
|
+
case 'kimi': return path.join(cwd, '.kimi', 'commands');
|
|
58
|
+
default: return null; // cline, kiro — see header note.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { canonTarget, getTargetSkillsDir, getTargetCommandsDir };
|
|
@@ -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
|
|
29
|
-
// uses the SAME `{ mcpServers: { <name>: {
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
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
|
-
//
|
|
36
|
-
//
|
|
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.
|
|
67
|
-
// protects ~/.claude.json
|
|
68
|
-
//
|
|
69
|
-
//
|
|
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
|
|
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,
|
package/scripts/memory/cli.js
CHANGED
|
@@ -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
|
+
};
|