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.
- package/CHANGELOG.md +208 -0
- package/CLAUDE.md +2 -2
- package/README.md +7 -4
- package/agents/super-agent.md +102 -0
- package/bin/kodelyth-ecc.js +57 -2
- package/commands/super-agent.md +47 -0
- package/hooks/memory/capture-stop.js +1 -1
- package/install.ps1 +12 -1
- package/install.sh +14 -1
- package/package.json +5 -2
- package/rules/common/agent-intent-routing.md +6 -1
- package/scripts/cli/menu.js +1 -1
- package/scripts/codebase/index.js +59 -2
- package/scripts/hooks/post-bash-command-log.js +4 -11
- package/scripts/hooks/session-start.js +25 -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/mcp/register-self.js +120 -3
- 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/instinct-inject.js +67 -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/rtk/index.js +43 -1
- 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/scripts/terse/ledger.js +16 -5
- package/skills/terse-mode/SKILL.md +1 -1
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// scripts/memory/instinct-inject.js
|
|
2
|
+
//
|
|
3
|
+
// Consume the structured instinct store (scripts/memory/instincts.js) at session
|
|
4
|
+
// start. That store is written by the correction-capture hook, but until now
|
|
5
|
+
// nothing ever READ it — all of its confidence / decay / outcome machinery ran
|
|
6
|
+
// and never influenced a single session. This module closes that loop: it picks
|
|
7
|
+
// the high-confidence, non-stale instincts relevant to the current project and
|
|
8
|
+
// formats them for injection into SessionStart context.
|
|
9
|
+
//
|
|
10
|
+
// Kept separate from session-start.js (which executes main() at import time) so
|
|
11
|
+
// the selection logic can be unit-tested without running the whole hook.
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const DEFAULT_MAX = 6;
|
|
15
|
+
const DEFAULT_THRESHOLD = 0.7;
|
|
16
|
+
|
|
17
|
+
// Pure selection + formatting. Given raw instinct records, keep the ones that
|
|
18
|
+
// are non-stale, confident enough, and in scope for `projectPath`; rank by
|
|
19
|
+
// confidence; cap at `max`; render as injectable markdown. No I/O — this is the
|
|
20
|
+
// unit the tests exercise.
|
|
21
|
+
function formatStructuredInstincts(instincts, { projectPath, max = DEFAULT_MAX, threshold = DEFAULT_THRESHOLD } = {}) {
|
|
22
|
+
const candidates = (Array.isArray(instincts) ? instincts : [])
|
|
23
|
+
.filter(i => i && typeof i.rule === 'string' && i.rule.trim())
|
|
24
|
+
.filter(i => !i.stale && (i.confidence || 0) >= threshold)
|
|
25
|
+
// Global instincts always apply; project-scoped ones only in their project.
|
|
26
|
+
// A record with no project_path is treated as unscoped (applies anywhere).
|
|
27
|
+
.filter(i => i.scope === 'global' || !i.project_path || i.project_path === projectPath);
|
|
28
|
+
|
|
29
|
+
if (candidates.length === 0) return '';
|
|
30
|
+
|
|
31
|
+
const ranked = candidates
|
|
32
|
+
.slice()
|
|
33
|
+
.sort((a, b) => (b.confidence || 0) - (a.confidence || 0))
|
|
34
|
+
.slice(0, max);
|
|
35
|
+
|
|
36
|
+
const lines = ranked.map(i => {
|
|
37
|
+
const scope = i.scope === 'project' ? 'project' : 'global';
|
|
38
|
+
const confidence = `${Math.round((i.confidence || 0) * 100)}%`;
|
|
39
|
+
return `- [${scope} ${confidence}] ${i.rule.trim()}`;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return `Learned instincts (from past corrections):\n${lines.join('\n')}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Read the store and format. Never throws: the structured store must not be able
|
|
46
|
+
// to break session start, so any failure degrades to injecting nothing.
|
|
47
|
+
function summarizeStructuredInstincts(projectPath, { log = () => {} } = {}) {
|
|
48
|
+
try {
|
|
49
|
+
const store = require('./instincts');
|
|
50
|
+
const summary = formatStructuredInstincts(store.list(), { projectPath });
|
|
51
|
+
if (summary) {
|
|
52
|
+
const count = summary.split('\n').length - 1;
|
|
53
|
+
log(`[SessionStart] Injecting ${count} learned instinct(s) from the structured store`);
|
|
54
|
+
}
|
|
55
|
+
return summary;
|
|
56
|
+
} catch (err) {
|
|
57
|
+
log(`[SessionStart] Structured instinct injection skipped: ${err && err.message}`);
|
|
58
|
+
return '';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = {
|
|
63
|
+
formatStructuredInstincts,
|
|
64
|
+
summarizeStructuredInstincts,
|
|
65
|
+
DEFAULT_MAX,
|
|
66
|
+
DEFAULT_THRESHOLD,
|
|
67
|
+
};
|
|
@@ -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
|
+
};
|
package/scripts/memory/store.js
CHANGED
|
@@ -19,6 +19,8 @@ const os = require('os');
|
|
|
19
19
|
const path = require('path');
|
|
20
20
|
const crypto = require('crypto');
|
|
21
21
|
const safeFs = require('../lib/safe-fs.js');
|
|
22
|
+
const pid = require('./project-id.js');
|
|
23
|
+
const { redact } = require('../lib/redact.js');
|
|
22
24
|
|
|
23
25
|
// Auto-migrate legacy ~/.kodelyth/ → ~/.kodelythecc/ before we touch any path.
|
|
24
26
|
try { require('../migrate-legacy').main(); } catch { /* best-effort */ }
|
|
@@ -27,11 +29,14 @@ const MEMORY_DIR = process.env.KODELYTH_MEMORY_DIR
|
|
|
27
29
|
|| path.join(os.homedir(), '.kodelythecc', 'memory');
|
|
28
30
|
|
|
29
31
|
const PATHS = {
|
|
30
|
-
dir:
|
|
31
|
-
log:
|
|
32
|
-
index:
|
|
33
|
-
patterns:
|
|
34
|
-
projects:
|
|
32
|
+
dir: MEMORY_DIR,
|
|
33
|
+
log: path.join(MEMORY_DIR, 'memories.jsonl'),
|
|
34
|
+
index: path.join(MEMORY_DIR, 'index.json'),
|
|
35
|
+
patterns: path.join(MEMORY_DIR, 'patterns.json'),
|
|
36
|
+
projects: path.join(MEMORY_DIR, 'projects'),
|
|
37
|
+
// Opt-in semantic layer only. Written when embeddings are enabled; absent and
|
|
38
|
+
// untouched on a default (BM25-only) install.
|
|
39
|
+
embeddings: path.join(MEMORY_DIR, 'embeddings.jsonl'),
|
|
35
40
|
};
|
|
36
41
|
|
|
37
42
|
// ── Stop words (English + common code noise) ─────────────────────────────────
|
|
@@ -208,6 +213,28 @@ function indexMemory(index, memory) {
|
|
|
208
213
|
*/
|
|
209
214
|
const RELATIVE_SCORE_FLOOR = 0.25;
|
|
210
215
|
|
|
216
|
+
// Rank fusion. BM25 decides RELEVANCE (and the floor below gates it); this only
|
|
217
|
+
// reorders the survivors so that, among similarly-relevant memories, a recent and
|
|
218
|
+
// proven one outranks an old or failed one. Pure function of one memory — the
|
|
219
|
+
// BM25 `score`, the `captured_at` timestamp, and the `resolved` outcome — so it
|
|
220
|
+
// is unit-tested directly.
|
|
221
|
+
function fuseScore(m) {
|
|
222
|
+
const base = m.score || 0;
|
|
223
|
+
// Recency: up to +25% for a just-captured memory, decaying over ~90 days.
|
|
224
|
+
let recency = 1;
|
|
225
|
+
const t = Date.parse(m.captured_at || '');
|
|
226
|
+
if (!Number.isNaN(t)) {
|
|
227
|
+
const ageDays = Math.max(0, (Date.now() - t) / 86400000);
|
|
228
|
+
recency = 1 + 0.25 * Math.exp(-ageDays / 90);
|
|
229
|
+
}
|
|
230
|
+
// Outcome: an approach confirmed to work (resolved:true) is worth more; one
|
|
231
|
+
// that didn't stick (resolved:false) is worth less. Unknown stays neutral.
|
|
232
|
+
let outcome = 1;
|
|
233
|
+
if (m.resolved === true) outcome = 1.3;
|
|
234
|
+
else if (m.resolved === false) outcome = 0.6;
|
|
235
|
+
return base * recency * outcome;
|
|
236
|
+
}
|
|
237
|
+
|
|
211
238
|
function search(query, options = {}) {
|
|
212
239
|
// minScore stays honoured when passed, so existing callers keep their exact
|
|
213
240
|
// behaviour; only the default changes from an absolute floor to a relative one.
|
|
@@ -253,9 +280,15 @@ function search(query, options = {}) {
|
|
|
253
280
|
.filter(Boolean);
|
|
254
281
|
|
|
255
282
|
if (projectFilter) {
|
|
256
|
-
|
|
283
|
+
// projectFilter may be a single id or a list of ids (read-both: the current
|
|
284
|
+
// git-remote id plus the legacy path id). Match on membership either way.
|
|
285
|
+
const allowed = Array.isArray(projectFilter) ? new Set(projectFilter) : new Set([projectFilter]);
|
|
286
|
+
results = results.filter(m => allowed.has(m.project));
|
|
257
287
|
}
|
|
258
288
|
|
|
289
|
+
// Re-rank the relevant survivors by the fused score (relevance × recency ×
|
|
290
|
+
// outcome), then trim. `score` (raw BM25) stays on each result for callers.
|
|
291
|
+
results.sort((a, b) => fuseScore(b) - fuseScore(a));
|
|
259
292
|
return results.slice(0, limit);
|
|
260
293
|
}
|
|
261
294
|
|
|
@@ -354,17 +387,23 @@ function capture({
|
|
|
354
387
|
const memory = {
|
|
355
388
|
id: newMemoryId(),
|
|
356
389
|
captured_at: new Date().toISOString(),
|
|
357
|
-
|
|
358
|
-
|
|
390
|
+
// Redact credentials BEFORE storing. A memory is written to memories.jsonl,
|
|
391
|
+
// surfaced by recall, and injected into future sessions — so a leaked key in
|
|
392
|
+
// a captured problem/approach would otherwise persist and re-surface.
|
|
393
|
+
problem: redact(String(problem)).slice(0, 500),
|
|
394
|
+
approach: redact(String(approach)).slice(0, 2000),
|
|
359
395
|
// The COUNT was capped but not the length of each tag, so a single
|
|
360
396
|
// multi-megabyte tag was stored whole — bloating the log, the index, and
|
|
361
397
|
// the memory block injected at session start. Bound both.
|
|
362
|
-
tags: Array.from(new Set(tags.map(t => String(t).slice(0, 60)))).slice(0, 20),
|
|
363
|
-
|
|
398
|
+
tags: Array.from(new Set(tags.map(t => redact(String(t)).slice(0, 60)))).slice(0, 20),
|
|
399
|
+
// Git-remote-first id: the same repo keeps its memories across clone / move /
|
|
400
|
+
// rename, where the old sha256(path) scheme orphaned them. Legacy memories
|
|
401
|
+
// (path id) are still found via read-both in recallForProject().
|
|
402
|
+
project: project ? pid.resolveProjectId(project) : null,
|
|
364
403
|
project_path: project,
|
|
365
404
|
language,
|
|
366
|
-
files: files.slice(0, 20).map(f => String(f).slice(0, 500)),
|
|
367
|
-
gotchas: gotchas.slice(0, 10).map(g => String(g).slice(0, 500)),
|
|
405
|
+
files: files.slice(0, 20).map(f => redact(String(f)).slice(0, 500)),
|
|
406
|
+
gotchas: gotchas.slice(0, 10).map(g => redact(String(g)).slice(0, 500)),
|
|
368
407
|
source,
|
|
369
408
|
};
|
|
370
409
|
// Load the index BEFORE writing the row. If index.json is missing or invalid,
|
|
@@ -383,7 +422,9 @@ function recall(query, options = {}) {
|
|
|
383
422
|
}
|
|
384
423
|
|
|
385
424
|
function recallForProject(projectRoot, query, options = {}) {
|
|
386
|
-
|
|
425
|
+
// Read-both: match the current git-remote id AND the legacy path id, so
|
|
426
|
+
// memories captured under either scheme are found.
|
|
427
|
+
const opts = { ...options, projectFilter: pid.matchingProjectIds(projectRoot) };
|
|
387
428
|
const results = search(query, opts);
|
|
388
429
|
if (results.length >= (options.limit || 5)) return results;
|
|
389
430
|
// Fall back to global memories if project-specific are sparse
|
|
@@ -525,6 +566,69 @@ function stats() {
|
|
|
525
566
|
};
|
|
526
567
|
}
|
|
527
568
|
|
|
569
|
+
// ── Optional semantic layer (opt-in; a default install never touches this) ───
|
|
570
|
+
// Embeddings live in a sidecar keyed by memory id, so memories.jsonl and its
|
|
571
|
+
// BM25 index are byte-for-byte identical whether or not embeddings are enabled.
|
|
572
|
+
|
|
573
|
+
function saveEmbedding(id, vec) {
|
|
574
|
+
if (!id || !Array.isArray(vec) || vec.length === 0) return false;
|
|
575
|
+
ensureDir(PATHS.dir);
|
|
576
|
+
fs.appendFileSync(PATHS.embeddings, JSON.stringify({ id, vec }) + '\n');
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function loadEmbeddings(ids = null) {
|
|
581
|
+
if (!fs.existsSync(PATHS.embeddings)) return {};
|
|
582
|
+
const want = ids ? new Set(ids) : null;
|
|
583
|
+
const out = {};
|
|
584
|
+
for (const line of fs.readFileSync(PATHS.embeddings, 'utf8').split('\n')) {
|
|
585
|
+
if (!line) continue;
|
|
586
|
+
let row; try { row = JSON.parse(line); } catch { continue; }
|
|
587
|
+
if (!row || !row.id || !Array.isArray(row.vec)) continue;
|
|
588
|
+
if (want && !want.has(row.id)) continue;
|
|
589
|
+
out[row.id] = row.vec; // append-only; last write wins
|
|
590
|
+
}
|
|
591
|
+
return out;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Capture a memory and, when embeddings are enabled AND a provider is available,
|
|
595
|
+
// also store its embedding. Otherwise behaves exactly like the sync capture().
|
|
596
|
+
async function captureWithEmbedding(args) {
|
|
597
|
+
const memory = capture(args);
|
|
598
|
+
try {
|
|
599
|
+
const provider = await require('./embeddings').getProvider();
|
|
600
|
+
if (provider) {
|
|
601
|
+
const vec = await provider.embed(`${memory.problem} ${memory.approach}`);
|
|
602
|
+
if (vec) saveEmbedding(memory.id, vec);
|
|
603
|
+
}
|
|
604
|
+
} catch { /* embeddings are best-effort — never fail a capture over them */ }
|
|
605
|
+
return memory;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Hybrid recall: BM25 selects the relevant set (sync, as always), then — only
|
|
609
|
+
// when embeddings are enabled and available — re-ranks that set by blending in
|
|
610
|
+
// semantic similarity. With embeddings off/unavailable this returns exactly what
|
|
611
|
+
// recall() returns.
|
|
612
|
+
async function recallHybrid(query, options = {}) {
|
|
613
|
+
const limit = options.limit || 5;
|
|
614
|
+
// Pull a wider candidate pool so semantic similarity can promote a strong
|
|
615
|
+
// match that BM25 alone ranked just below the cut.
|
|
616
|
+
const base = search(query, { ...options, limit: limit * 4 });
|
|
617
|
+
if (base.length === 0) return base;
|
|
618
|
+
try {
|
|
619
|
+
const emb = require('./embeddings');
|
|
620
|
+
const provider = await emb.getProvider();
|
|
621
|
+
if (!provider) return base.slice(0, limit);
|
|
622
|
+
const queryVec = await provider.embed(query);
|
|
623
|
+
if (!queryVec) return base.slice(0, limit);
|
|
624
|
+
const vecById = loadEmbeddings(base.map(m => m.id));
|
|
625
|
+
const fused = emb.fuseHybrid(base, { queryVec, vecById, weight: options.semanticWeight });
|
|
626
|
+
return fused.map(({ _bm25norm, _semantic, _hybrid, ...m }) => m).slice(0, limit);
|
|
627
|
+
} catch {
|
|
628
|
+
return base.slice(0, limit); // any failure in the opt-in path degrades to BM25
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
528
632
|
module.exports = {
|
|
529
633
|
PATHS,
|
|
530
634
|
capture,
|
|
@@ -536,6 +640,12 @@ module.exports = {
|
|
|
536
640
|
stats,
|
|
537
641
|
tokenise,
|
|
538
642
|
projectHash,
|
|
643
|
+
fuseScore,
|
|
644
|
+
// Opt-in semantic layer
|
|
645
|
+
saveEmbedding,
|
|
646
|
+
loadEmbeddings,
|
|
647
|
+
captureWithEmbedding,
|
|
648
|
+
recallHybrid,
|
|
539
649
|
// Improvement C
|
|
540
650
|
resolveMemory,
|
|
541
651
|
findMemoriesForFile,
|
package/scripts/rtk/index.js
CHANGED
|
@@ -64,11 +64,51 @@ function detectInstalledTargets() {
|
|
|
64
64
|
return targets;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// Guard against the `rtk` name collision. reachingforthejack/rtk (Rust Type Kit)
|
|
68
|
+
// also ships a binary called `rtk`; wiring or measuring it as if it were Rust
|
|
69
|
+
// Token Killer would be wrong and silent — ECC would patch hooks and read a
|
|
70
|
+
// `gain` ledger that does not exist. Rust Token Killer is the only `rtk` with a
|
|
71
|
+
// `gain` subcommand (the token-savings ledger ECC reads), so probing it
|
|
72
|
+
// distinguishes the two with no side effects and without depending on exact
|
|
73
|
+
// `--version` text. `rtk gain` is the documented signature command in RTK.md.
|
|
74
|
+
//
|
|
75
|
+
// The decision is a pure function of two probe results so it can be unit-tested
|
|
76
|
+
// without spawning a binary:
|
|
77
|
+
// gainHelpOk — did `rtk gain --help` exit 0?
|
|
78
|
+
// helpText — stdout of `rtk --help` (or '' if it failed)
|
|
79
|
+
function looksLikeRealRtk({ gainHelpOk, helpText } = {}) {
|
|
80
|
+
if (gainHelpOk) return true;
|
|
81
|
+
const h = String(helpText || '');
|
|
82
|
+
// The token killer's help lists the `gain` command and calls itself an
|
|
83
|
+
// LLM-output proxy; Rust Type Kit's help does neither.
|
|
84
|
+
return /\bgain\b/.test(h) && /(LLM|token|proxy)/i.test(h);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isRealRtk() {
|
|
88
|
+
let gainHelpOk = false;
|
|
89
|
+
try {
|
|
90
|
+
execFileSync('rtk', ['gain', '--help'], { stdio: 'ignore' });
|
|
91
|
+
gainHelpOk = true;
|
|
92
|
+
} catch { /* `gain` subcommand absent, or `rtk` not runnable */ }
|
|
93
|
+
|
|
94
|
+
let helpText = '';
|
|
95
|
+
if (!gainHelpOk) {
|
|
96
|
+
try {
|
|
97
|
+
helpText = execFileSync('rtk', ['--help'], {
|
|
98
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
99
|
+
});
|
|
100
|
+
} catch { /* no help available */ }
|
|
101
|
+
}
|
|
102
|
+
return looksLikeRealRtk({ gainHelpOk, helpText });
|
|
103
|
+
}
|
|
104
|
+
|
|
67
105
|
function isInstalled() {
|
|
68
106
|
try {
|
|
69
107
|
execFileSync('rtk', ['--version'], { stdio: 'ignore' });
|
|
70
|
-
return true;
|
|
71
108
|
} catch { return false; }
|
|
109
|
+
// A binary named `rtk` exists — confirm it is Rust Token Killer, not the Rust
|
|
110
|
+
// Type Kit collision, before any caller wires or measures it.
|
|
111
|
+
return isRealRtk();
|
|
72
112
|
}
|
|
73
113
|
|
|
74
114
|
function getVersion() {
|
|
@@ -214,6 +254,8 @@ module.exports = {
|
|
|
214
254
|
normalizeTarget,
|
|
215
255
|
detectInstalledTargets,
|
|
216
256
|
isInstalled,
|
|
257
|
+
isRealRtk,
|
|
258
|
+
looksLikeRealRtk,
|
|
217
259
|
getVersion,
|
|
218
260
|
install,
|
|
219
261
|
enableFor,
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// scripts/super-agent/cli.js
|
|
2
|
+
//
|
|
3
|
+
// The driver surface for the super-agent orchestrator. Like the Arena CLI, this
|
|
4
|
+
// does NOT run agents itself — it advances a tokenless state machine and tells
|
|
5
|
+
// the driving LLM what to do next. The loop is:
|
|
6
|
+
//
|
|
7
|
+
// super-agent start --goal "..." → create a run, print the next action
|
|
8
|
+
// super-agent next <id> → print the next action (JSON)
|
|
9
|
+
// super-agent plan <id> --file p.json → install the decomposed step DAG
|
|
10
|
+
// super-agent step <id> <step> --status done|failed [--branch b] [--tokens n]
|
|
11
|
+
// super-agent integrate <id> [--conflicts a,b] [--tokens n]
|
|
12
|
+
// super-agent verify <id> --passed|--failed [--detail "..."]
|
|
13
|
+
// super-agent status <id> | list
|
|
14
|
+
//
|
|
15
|
+
// The LLM performs each action with the existing primitives (blueprint to
|
|
16
|
+
// decompose, the swarm to dispatch, the GAN loop to evaluate, git to integrate)
|
|
17
|
+
// and reports back through the submit commands.
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const state = require('./state.js');
|
|
22
|
+
const orch = require('./orchestrator.js');
|
|
23
|
+
|
|
24
|
+
function parse(argv) {
|
|
25
|
+
const positional = [];
|
|
26
|
+
const flags = {};
|
|
27
|
+
for (let i = 0; i < argv.length; i++) {
|
|
28
|
+
const a = argv[i];
|
|
29
|
+
if (a.startsWith('--')) {
|
|
30
|
+
const key = a.slice(2);
|
|
31
|
+
const next = argv[i + 1];
|
|
32
|
+
if (next && !next.startsWith('--')) { flags[key] = next; i++; }
|
|
33
|
+
else flags[key] = true;
|
|
34
|
+
} else positional.push(a);
|
|
35
|
+
}
|
|
36
|
+
return { positional, flags };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const out = (o) => process.stdout.write(JSON.stringify(o, null, 2) + '\n');
|
|
40
|
+
const err = (m) => { process.stderr.write(`super-agent: ${m}\n`); process.exit(2); };
|
|
41
|
+
|
|
42
|
+
function loadOr(id) {
|
|
43
|
+
if (!id) err('a run id is required');
|
|
44
|
+
if (!state.exists(id)) err(`no run ${id}`);
|
|
45
|
+
return state.load(id);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function main(argv) {
|
|
49
|
+
const { positional, flags } = parse(argv);
|
|
50
|
+
const cmd = positional[0];
|
|
51
|
+
|
|
52
|
+
switch (cmd) {
|
|
53
|
+
case 'start': {
|
|
54
|
+
const goal = flags.goal || positional.slice(1).join(' ');
|
|
55
|
+
if (!goal) err('usage: super-agent start --goal "<objective>" [--token-cap N] [--step-cap N] [--scope .]');
|
|
56
|
+
const run = state.createRun({
|
|
57
|
+
goal,
|
|
58
|
+
scope: flags.scope || '.',
|
|
59
|
+
limits: { tokenCap: Number(flags['token-cap']) || 0, stepCap: Number(flags['step-cap']) || 0 },
|
|
60
|
+
});
|
|
61
|
+
state.save(run);
|
|
62
|
+
out({ runId: run.id, next: orch.nextAction(run) });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
case 'next': {
|
|
66
|
+
out(orch.nextAction(loadOr(positional[1])));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
case 'plan': {
|
|
70
|
+
const run = loadOr(positional[1]);
|
|
71
|
+
if (!flags.file) err('usage: super-agent plan <id> --file <plan.json> (JSON array of { id, description, deps, exitCriteria })');
|
|
72
|
+
let steps;
|
|
73
|
+
try { steps = JSON.parse(fs.readFileSync(flags.file, 'utf8')); }
|
|
74
|
+
catch (e) { err(`cannot read plan: ${e.message}`); }
|
|
75
|
+
orch.submitPlan(run, steps);
|
|
76
|
+
out({ ok: true, next: orch.nextAction(run) });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
case 'dispatch': {
|
|
80
|
+
// Mark the given steps in-progress (optional bookkeeping before running them).
|
|
81
|
+
const run = loadOr(positional[1]);
|
|
82
|
+
const ids = (flags.steps ? String(flags.steps).split(',') : positional.slice(2)).filter(Boolean);
|
|
83
|
+
orch.markDispatched(run, ids);
|
|
84
|
+
out({ ok: true, inProgress: ids });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
case 'step': {
|
|
88
|
+
const run = loadOr(positional[1]);
|
|
89
|
+
const stepId = positional[2];
|
|
90
|
+
if (!stepId) err('usage: super-agent step <id> <stepId> --status done|failed [--branch b] [--tokens n]');
|
|
91
|
+
orch.submitStepResult(run, stepId, {
|
|
92
|
+
status: flags.status === 'failed' ? 'failed' : 'done',
|
|
93
|
+
branch: flags.branch || null,
|
|
94
|
+
result: flags.result || null,
|
|
95
|
+
tokensSpent: Number(flags.tokens) || 0,
|
|
96
|
+
});
|
|
97
|
+
out({ ok: true, next: orch.nextAction(run) });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
case 'integrate': {
|
|
101
|
+
const run = loadOr(positional[1]);
|
|
102
|
+
const conflicts = flags.conflicts ? String(flags.conflicts).split(',').filter(Boolean) : [];
|
|
103
|
+
orch.submitIntegration(run, {
|
|
104
|
+
merged: conflicts.length === 0 && flags.merged !== false,
|
|
105
|
+
conflicts,
|
|
106
|
+
tokensSpent: Number(flags.tokens) || 0,
|
|
107
|
+
});
|
|
108
|
+
out({ ok: true, next: orch.nextAction(run) });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
case 'verify': {
|
|
112
|
+
const run = loadOr(positional[1]);
|
|
113
|
+
if (!flags.passed && !flags.failed) err('usage: super-agent verify <id> --passed | --failed [--detail "..."]');
|
|
114
|
+
orch.submitVerification(run, {
|
|
115
|
+
passed: !!flags.passed && !flags.failed,
|
|
116
|
+
detail: typeof flags.detail === 'string' ? flags.detail : '',
|
|
117
|
+
tokensSpent: Number(flags.tokens) || 0,
|
|
118
|
+
});
|
|
119
|
+
out({ ok: true, next: orch.nextAction(run) });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
case 'status': {
|
|
123
|
+
out(state.summarize(loadOr(positional[1])));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
case 'list': {
|
|
127
|
+
out(state.list().map(state.summarize));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
default:
|
|
131
|
+
err('commands: start | next | plan | dispatch | step | integrate | verify | status | list');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = { main, parse };
|