kodelyth-ecc 1.5.10 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/.github/workflows/publish.yml +19 -0
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +447 -0
  4. package/CLAUDE.md +48 -16
  5. package/README.md +265 -81
  6. package/VERSION +1 -1
  7. package/actions/ecc-review/README.md +243 -0
  8. package/actions/ecc-review/action.yml +158 -0
  9. package/actions/ecc-review/post-comment.js +171 -0
  10. package/actions/ecc-review/run-review.js +285 -0
  11. package/agents/backdoor-hunter.md +260 -0
  12. package/agents/chaos-engineer.md +251 -0
  13. package/agents/code-stealer-detector.md +228 -0
  14. package/agents/jailbreak-tester.md +222 -0
  15. package/agents/license-violation-finder.md +212 -0
  16. package/agents/prompt-injection-hunter.md +126 -0
  17. package/agents/secret-hunter.md +209 -0
  18. package/agents/supply-chain-auditor.md +195 -0
  19. package/bin/kodelyth-ecc.js +875 -1
  20. package/bundles/enterprise.md +172 -0
  21. package/bundles/indie-hacker.md +106 -0
  22. package/bundles/red-team.md +138 -0
  23. package/cat +0 -0
  24. package/commands/dashboard.md +67 -0
  25. package/commands/devil-mode.md +121 -0
  26. package/commands/memory-evolve.md +71 -0
  27. package/commands/replay.md +61 -0
  28. package/commands/route-model.md +48 -0
  29. package/commands/swarm.md +68 -0
  30. package/commands/verify-supply-chain.md +59 -0
  31. package/docs/dashboard.md +211 -0
  32. package/docs/evolve.md +303 -0
  33. package/docs/mcp-clients.md +167 -0
  34. package/docs/mcp.md +178 -0
  35. package/docs/replay.md +244 -0
  36. package/docs/supply-chain.md +207 -0
  37. package/docs/swarm.md +243 -0
  38. package/hooks/hooks.json +52 -0
  39. package/hooks/memory/auto-recall.js +29 -1
  40. package/hooks/safety/README.md +124 -0
  41. package/hooks/safety/lib/patterns.js +179 -0
  42. package/hooks/safety/prompt-injection-guard.js +179 -0
  43. package/hooks/safety/token-budget.js +229 -0
  44. package/install.ps1 +199 -2
  45. package/install.sh +285 -3
  46. package/package.json +30 -4
  47. package/rules/common/agent-intent-routing.md +182 -0
  48. package/rules/common/cost-aware-model-routing.md +152 -0
  49. package/scripts/dashboard/data.js +353 -0
  50. package/scripts/dashboard/server.js +324 -0
  51. package/scripts/dashboard/static/index.html +645 -0
  52. package/scripts/evolve/analyze.js +303 -0
  53. package/scripts/evolve/proposals.js +162 -0
  54. package/scripts/evolve/stats.js +219 -0
  55. package/scripts/mcp/catalog.js +244 -0
  56. package/scripts/mcp/client.js +181 -0
  57. package/scripts/mcp/prompts.js +133 -0
  58. package/scripts/mcp/resources.js +94 -0
  59. package/scripts/mcp/server.js +153 -0
  60. package/scripts/mcp/tools.js +465 -0
  61. package/scripts/replay/bundle.js +191 -0
  62. package/scripts/replay/replay.js +107 -0
  63. package/scripts/router/classify.js +232 -0
  64. package/scripts/supply-chain/manifest.js +154 -0
  65. package/scripts/supply-chain/sbom.js +202 -0
  66. package/scripts/supply-chain/verify.js +102 -0
  67. package/scripts/swarm/build-plan.js +193 -0
  68. package/skills/cost-aware-model-routing/SKILL.md +153 -0
  69. package/skills/kodelyth-quickstart/SKILL.md +7 -0
  70. package/skills/observability-dashboard/SKILL.md +119 -0
  71. package/skills/self-evolving-memory/SKILL.md +175 -0
  72. package/skills/session-replay/SKILL.md +199 -0
  73. package/skills/supply-chain-verification/SKILL.md +201 -0
  74. package/skills/swarm-orchestrator/SKILL.md +177 -0
  75. package/social/card-install.svg +1 -1
  76. package/social/facebook-group/POST.md +121 -0
  77. package/social/facebook-group/fb-1-3am-debug.png +0 -0
  78. package/social/facebook-group/fb-1-3am-debug.svg +97 -0
  79. package/social/facebook-group/fb-2-cpu-upgrade.png +0 -0
  80. package/social/facebook-group/fb-2-cpu-upgrade.svg +132 -0
  81. package/social/facebook-group/fb-3-before-after.png +0 -0
  82. package/social/facebook-group/fb-3-before-after.svg +94 -0
  83. package/social/facebook-v150.svg +5 -5
  84. package/social/github-social-preview.svg +119 -100
  85. package/social/readme-hero.svg +11 -11
  86. package/social/section-agents.svg +57 -0
  87. package/social/section-author.svg +54 -0
  88. package/social/section-dashboard.svg +59 -0
  89. package/social/section-devil.svg +54 -0
  90. package/social/section-hooks.svg +51 -0
  91. package/social/section-install.svg +42 -0
  92. package/social/section-learning.svg +52 -0
  93. package/social/section-mcp.svg +46 -0
  94. package/social/section-memory.svg +57 -0
  95. package/social/section-parallel.svg +72 -0
  96. package/social/section-routing.svg +51 -0
  97. package/social/x-card-agents-grid.svg +6 -6
  98. package/tests/dashboard/data.test.js +235 -0
  99. package/tests/dashboard/server.test.js +240 -0
  100. package/tests/evolve/analyze.test.js +169 -0
  101. package/tests/evolve/proposals.test.js +173 -0
  102. package/tests/evolve/stats.test.js +159 -0
  103. package/tests/mcp/catalog.test.js +98 -0
  104. package/tests/mcp/client.test.js +109 -0
  105. package/tests/mcp/resources-prompts.test.js +70 -0
  106. package/tests/mcp/tools.test.js +159 -0
  107. package/tests/replay/bundle.test.js +181 -0
  108. package/tests/replay/replay.test.js +131 -0
  109. package/tests/router/classify.test.js +164 -0
  110. package/tests/safety/patterns.test.js +94 -0
  111. package/tests/safety/prompt-injection-guard.test.js +111 -0
  112. package/tests/safety/token-budget.test.js +119 -0
  113. package/tests/supply-chain/manifest.test.js +147 -0
  114. package/tests/supply-chain/sbom.test.js +170 -0
  115. package/tests/supply-chain/verify.test.js +146 -0
  116. package/tests/swarm/build-plan.test.js +188 -0
  117. package/wiki/Agent-Reference.md +58 -7
  118. package/wiki/FAQ.md +204 -7
  119. package/wiki/Home.md +104 -28
  120. package/wiki/Hook-Reference.md +1 -1
  121. package/wiki/Installation-Guide.md +109 -6
  122. package/wiki/Platform-Support.md +239 -25
  123. package/wiki/Skill-Reference.md +101 -6
@@ -0,0 +1,303 @@
1
+ // scripts/evolve/analyze.js
2
+ //
3
+ // Phase 3.4 — Self-evolving memory: signal → proposal logic.
4
+ //
5
+ // Pure, deterministic functions. No I/O. Given:
6
+ // - reuse stats (from stats.getReuseStats)
7
+ // - routing-miss stats (from stats.getRoutingMissStats)
8
+ // - the memories themselves (from scripts/memory/store.listAll)
9
+ // - the current routing rule text (string)
10
+ //
11
+ // Produces a list of structured proposals. Two kinds:
12
+ //
13
+ // 1) skill-upgrade — a memory has been surfaced ≥ N times and across
14
+ // ≥ M distinct sessions → propose creating a draft
15
+ // SKILL.md from it.
16
+ //
17
+ // 2) routing-addition — ≥ K substantive prompts cluster on the same token
18
+ // set without any memory match → propose adding a
19
+ // new routing entry under the most-likely tier.
20
+ //
21
+ // Every proposal carries:
22
+ // - id (deterministic given evidence — stable across re-runs)
23
+ // - type
24
+ // - evidence (the raw signal data)
25
+ // - proposal: { kind, target_path, diff (markdown), rationale }
26
+ //
27
+ // Apply step writes the diff to disk as a draft file the user reviews
28
+ // before committing. NEVER auto-applied.
29
+ 'use strict';
30
+
31
+ const crypto = require('crypto');
32
+
33
+ const DEFAULT_REUSE_MIN_COUNT = 3;
34
+ const DEFAULT_REUSE_MIN_SESSIONS = 2;
35
+ const DEFAULT_MISS_MIN_COUNT = 3;
36
+ const DEFAULT_MISS_CLUSTER_MIN = 2; // distinct prompt-hashes per cluster
37
+
38
+ // ── helpers ──────────────────────────────────────────────────────────────────
39
+
40
+ function deterministicId(prefix, payload) {
41
+ const h = crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, 10);
42
+ return `${prefix}-${h}`;
43
+ }
44
+
45
+ function slugify(s) {
46
+ return String(s || '')
47
+ .toLowerCase()
48
+ .replace(/[^a-z0-9]+/g, '-')
49
+ .replace(/^-+|-+$/g, '')
50
+ .slice(0, 60) || 'untitled';
51
+ }
52
+
53
+ // ── skill-upgrade proposals ─────────────────────────────────────────────────
54
+
55
+ function buildSkillUpgradeMarkdown(memory) {
56
+ const slug = slugify(memory.problem);
57
+ const tags = (memory.tags || []).slice(0, 8);
58
+ const desc = (memory.problem || '').slice(0, 180);
59
+ const approach = (memory.approach || '').trim();
60
+ const lang = memory.language || 'general';
61
+
62
+ return [
63
+ '---',
64
+ `name: ${slug}`,
65
+ `description: ${desc.replace(/[\r\n]+/g, ' ')} (auto-derived from memory ${memory.id} after repeated reuse)`,
66
+ `origin: kodelyth-evolve`,
67
+ `language: ${lang}`,
68
+ `tags:`,
69
+ ...tags.map(t => ` - ${t}`),
70
+ '---',
71
+ '',
72
+ `# Skill: ${slug}`,
73
+ '',
74
+ '> **Auto-derived draft.** Generated by `kodelyth-ecc evolve` after this memory was surfaced repeatedly across multiple sessions. Review, refine, and rename before committing.',
75
+ '',
76
+ '## Problem',
77
+ '',
78
+ memory.problem || '_(no problem statement)_',
79
+ '',
80
+ '## Approach',
81
+ '',
82
+ approach || '_(no approach text)_',
83
+ '',
84
+ memory.gotchas?.length ? '## Gotchas\n\n' + memory.gotchas.map(g => `- ${g}`).join('\n') + '\n' : '',
85
+ '## When to invoke',
86
+ '',
87
+ '_Replace this section with explicit trigger conditions. Examples that worked:_',
88
+ '',
89
+ '_(add 2-3 real prompts that historically benefitted from this approach)_',
90
+ '',
91
+ '## Origin',
92
+ '',
93
+ `- Memory id: \`${memory.id}\``,
94
+ `- Captured at: ${memory.captured_at || 'unknown'}`,
95
+ `- Source: ${memory.source || 'unknown'}`,
96
+ `- Auto-promoted by Phase 3.4 self-evolving memory.`,
97
+ '',
98
+ ].filter(Boolean).join('\n');
99
+ }
100
+
101
+ function analyzeReuseForProposals({
102
+ reuseStats,
103
+ memories,
104
+ thresholds: t = {},
105
+ } = {}) {
106
+ if (!reuseStats || !Array.isArray(memories)) return [];
107
+ const minCount = t.minCount ?? DEFAULT_REUSE_MIN_COUNT;
108
+ const minSessions = t.minSessions ?? DEFAULT_REUSE_MIN_SESSIONS;
109
+
110
+ const memoriesById = new Map(memories.map(m => [m.id, m]));
111
+ const proposals = [];
112
+
113
+ for (const entry of reuseStats.entries || []) {
114
+ if (entry.count < minCount) continue;
115
+ if ((entry.sessions || []).length < minSessions) continue;
116
+ const memory = memoriesById.get(entry.id);
117
+ if (!memory) continue;
118
+
119
+ const targetPath = `skills/${slugify(memory.problem)}/SKILL.md`;
120
+ const evidence = {
121
+ memoryId: entry.id,
122
+ reuseCount: entry.count,
123
+ sessions: entry.sessions,
124
+ projects: entry.projects || [],
125
+ firstSurfaced: entry.firstSurfaced,
126
+ lastSurfaced: entry.lastSurfaced,
127
+ };
128
+ proposals.push({
129
+ id: deterministicId('skill', evidence),
130
+ type: 'skill-upgrade',
131
+ evidence,
132
+ proposal: {
133
+ kind: 'create-skill',
134
+ target_path: targetPath,
135
+ diff: buildSkillUpgradeMarkdown(memory),
136
+ rationale:
137
+ `Memory "${memory.problem}" surfaced ${entry.count}x across ` +
138
+ `${(entry.sessions || []).length} sessions — promoting to a draft skill.`,
139
+ },
140
+ });
141
+ }
142
+ return proposals;
143
+ }
144
+
145
+ // ── routing-addition proposals ──────────────────────────────────────────────
146
+
147
+ const STOP = new Set([
148
+ 'the','a','an','to','of','in','on','for','and','or','but','if','is','are',
149
+ 'was','were','be','been','have','has','had','do','does','did','this','that',
150
+ 'it','its','i','you','we','they','my','your','our','can','will','should',
151
+ 'how','what','why','when','where','use','using','run','make','need','want',
152
+ ]);
153
+
154
+ function topTokens(tokens, k = 4) {
155
+ const filtered = (tokens || []).filter(t => t.length >= 3 && !STOP.has(t));
156
+ return filtered.slice(0, k);
157
+ }
158
+
159
+ /**
160
+ * Cluster routing-miss entries by their top-K tokens. Two entries are in
161
+ * the same cluster if their top-K token sets share at least 2 tokens.
162
+ * Greedy / O(n²) — fine for thousands of misses, this isn't BM25.
163
+ */
164
+ function clusterMisses(missEntries) {
165
+ const clusters = [];
166
+ for (const m of missEntries) {
167
+ const myTokens = topTokens(m.tokens);
168
+ if (myTokens.length === 0) continue;
169
+ let placed = false;
170
+ for (const c of clusters) {
171
+ const overlap = myTokens.filter(t => c.tokens.has(t)).length;
172
+ if (overlap >= 2) {
173
+ c.entries.push(m);
174
+ c.count += m.count;
175
+ myTokens.forEach(t => c.tokens.add(t));
176
+ placed = true;
177
+ break;
178
+ }
179
+ }
180
+ if (!placed) {
181
+ clusters.push({
182
+ tokens: new Set(myTokens),
183
+ entries: [m],
184
+ count: m.count,
185
+ });
186
+ }
187
+ }
188
+ return clusters
189
+ .map(c => ({
190
+ tokens: [...c.tokens],
191
+ entries: c.entries,
192
+ count: c.count,
193
+ }))
194
+ .sort((a, b) => b.count - a.count);
195
+ }
196
+
197
+ function buildRoutingProposalMarkdown(cluster) {
198
+ const sampleTokens = cluster.tokens.slice(0, 4).join(' / ');
199
+ const sampleLines = cluster.entries
200
+ .flatMap(e => e.samples || [])
201
+ .slice(0, 5)
202
+ .map(s => ` - "${s.replace(/\n/g, ' ').slice(0, 140)}"`)
203
+ .join('\n');
204
+ return [
205
+ '<!-- proposed addition to rules/common/agent-intent-routing.md -->',
206
+ '<!-- review by hand, decide on tier + agent, then merge -->',
207
+ '',
208
+ `### TODO-agent — covers prompts about ${sampleTokens}`,
209
+ '',
210
+ `Trigger if the user mentions ${cluster.tokens.slice(0, 6).map(t => `\`${t}\``).join(', ')}.`,
211
+ '',
212
+ '| Signal | Real human phrasing |',
213
+ '|---|---|',
214
+ `| repeated unrouted prompt cluster | ${cluster.tokens.slice(0, 5).join(', ')} |`,
215
+ '',
216
+ `**Origin:** Phase 3.4 self-evolving memory \u2014 ${cluster.count} prompts in this cluster were submitted with no memory match and (presumably) no specialist routing.`,
217
+ '',
218
+ 'Recent samples:',
219
+ sampleLines || ' _(none)_',
220
+ '',
221
+ '_(Pick the right tier in `rules/common/agent-intent-routing.md` before merging. Do NOT commit this block as-is — replace `TODO-agent` with the real agent name and slot under the correct priority tier.)_',
222
+ '',
223
+ ].join('\n');
224
+ }
225
+
226
+ function analyzeMissesForProposals({
227
+ missStats,
228
+ thresholds: t = {},
229
+ } = {}) {
230
+ if (!missStats) return [];
231
+ const minCount = t.minCount ?? DEFAULT_MISS_MIN_COUNT;
232
+ const minClusterDistinct = t.minClusterDistinct ?? DEFAULT_MISS_CLUSTER_MIN;
233
+
234
+ const clusters = clusterMisses(missStats.entries || []);
235
+ const proposals = [];
236
+
237
+ for (const c of clusters) {
238
+ if (c.count < minCount) continue;
239
+ if (c.entries.length < minClusterDistinct) continue;
240
+ const evidence = {
241
+ tokens: c.tokens,
242
+ occurrences: c.count,
243
+ distinct_prompts: c.entries.length,
244
+ sample_prompts: c.entries.flatMap(e => e.samples || []).slice(0, 5),
245
+ };
246
+ proposals.push({
247
+ id: deterministicId('route', { tokens: c.tokens.sort() }),
248
+ type: 'routing-addition',
249
+ evidence,
250
+ proposal: {
251
+ kind: 'add-routing-entry',
252
+ target_path: 'rules/common/agent-intent-routing.md',
253
+ diff: buildRoutingProposalMarkdown(c),
254
+ rationale:
255
+ `${c.count} substantive prompts (${c.entries.length} distinct) clustered on ` +
256
+ `tokens [${c.tokens.slice(0, 4).join(', ')}] without any memory match. ` +
257
+ `Likely a routing gap.`,
258
+ },
259
+ });
260
+ }
261
+ return proposals;
262
+ }
263
+
264
+ // ── single entry point ──────────────────────────────────────────────────────
265
+
266
+ function analyzeAll({
267
+ reuseStats,
268
+ missStats,
269
+ memories,
270
+ thresholds = {},
271
+ } = {}) {
272
+ return [
273
+ ...analyzeReuseForProposals({
274
+ reuseStats,
275
+ memories: memories || [],
276
+ thresholds: {
277
+ minCount: thresholds.reuseMinCount,
278
+ minSessions: thresholds.reuseMinSessions,
279
+ },
280
+ }),
281
+ ...analyzeMissesForProposals({
282
+ missStats,
283
+ thresholds: {
284
+ minCount: thresholds.missMinCount,
285
+ minClusterDistinct: thresholds.missMinClusterDistinct,
286
+ },
287
+ }),
288
+ ];
289
+ }
290
+
291
+ module.exports = {
292
+ DEFAULT_REUSE_MIN_COUNT,
293
+ DEFAULT_REUSE_MIN_SESSIONS,
294
+ DEFAULT_MISS_MIN_COUNT,
295
+ DEFAULT_MISS_CLUSTER_MIN,
296
+ analyzeReuseForProposals,
297
+ analyzeMissesForProposals,
298
+ analyzeAll,
299
+ buildSkillUpgradeMarkdown,
300
+ buildRoutingProposalMarkdown,
301
+ clusterMisses,
302
+ _internals: { topTokens, slugify, deterministicId },
303
+ };
@@ -0,0 +1,162 @@
1
+ // scripts/evolve/proposals.js
2
+ //
3
+ // Phase 3.4 — Self-evolving memory: proposal log.
4
+ //
5
+ // Append-only JSONL store of proposals with state transitions:
6
+ // pending → initial state when written
7
+ // accepted → user ran `evolve accept <id>`; draft file written to disk
8
+ // rejected → user ran `evolve reject <id>`
9
+ // applied → user committed the accepted draft (advisory, set manually)
10
+ //
11
+ // State changes are append-only (a new line per state event), so the full
12
+ // audit trail is preserved. Reads collapse to "latest state per id".
13
+ //
14
+ // Storage:
15
+ // ${KODELYTH_EVOLVE_DIR:-~/.kodelyth/evolve}/proposals.jsonl
16
+ //
17
+ // Pure where possible. Public functions accept dir explicitly.
18
+ 'use strict';
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const os = require('os');
23
+
24
+ const DEFAULT_DIR = process.env.KODELYTH_EVOLVE_DIR
25
+ || path.join(os.homedir(), '.kodelyth', 'evolve');
26
+
27
+ const PROPOSALS_FILE = 'proposals.jsonl';
28
+
29
+ const VALID_STATUSES = new Set(['pending', 'accepted', 'rejected', 'applied']);
30
+
31
+ function ensureDir(dir) {
32
+ try {
33
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
34
+ return true;
35
+ } catch { return false; }
36
+ }
37
+
38
+ function readAll(dir = DEFAULT_DIR) {
39
+ const p = path.join(dir, PROPOSALS_FILE);
40
+ if (!fs.existsSync(p)) return [];
41
+ try {
42
+ return fs.readFileSync(p, 'utf8')
43
+ .split('\n')
44
+ .filter(Boolean)
45
+ .map(line => { try { return JSON.parse(line); } catch { return null; } })
46
+ .filter(Boolean);
47
+ } catch { return []; }
48
+ }
49
+
50
+ /**
51
+ * Reduce raw event log → latest state per proposal id.
52
+ * Returns an ordered array (insertion order of first event per id).
53
+ */
54
+ function readLatest(dir = DEFAULT_DIR) {
55
+ const events = readAll(dir);
56
+ const byId = new Map();
57
+ for (const ev of events) {
58
+ if (!ev.id) continue;
59
+ if (!byId.has(ev.id)) {
60
+ byId.set(ev.id, { ...ev });
61
+ } else {
62
+ const cur = byId.get(ev.id);
63
+ // Merge: keep original proposal/evidence, update mutable fields.
64
+ cur.status = ev.status || cur.status;
65
+ cur.last_updated = ev.timestamp || cur.last_updated;
66
+ cur.applied_path = ev.applied_path || cur.applied_path;
67
+ cur.note = ev.note ?? cur.note;
68
+ }
69
+ }
70
+ return [...byId.values()];
71
+ }
72
+
73
+ function appendEvent(event, dir = DEFAULT_DIR) {
74
+ if (!ensureDir(dir)) return false;
75
+ try {
76
+ fs.appendFileSync(path.join(dir, PROPOSALS_FILE), JSON.stringify(event) + '\n');
77
+ return true;
78
+ } catch { return false; }
79
+ }
80
+
81
+ /**
82
+ * Append a fresh proposal with status=pending. Idempotent on `id`:
83
+ * if a proposal with this id already exists in the log, this is a no-op.
84
+ *
85
+ * @returns the proposal (existing or new)
86
+ */
87
+ function appendProposal(proposal, dir = DEFAULT_DIR) {
88
+ if (!proposal || !proposal.id || !proposal.type || !proposal.proposal) {
89
+ throw new Error('appendProposal: proposal must include id, type, and proposal{}');
90
+ }
91
+ const existing = readLatest(dir).find(p => p.id === proposal.id);
92
+ if (existing) return existing;
93
+
94
+ const event = {
95
+ ...proposal,
96
+ status: 'pending',
97
+ created_at: new Date().toISOString(),
98
+ timestamp: new Date().toISOString(),
99
+ };
100
+ appendEvent(event, dir);
101
+ return event;
102
+ }
103
+
104
+ function setStatus(id, status, dir = DEFAULT_DIR, extras = {}) {
105
+ if (!VALID_STATUSES.has(status)) {
106
+ throw new Error(`setStatus: invalid status "${status}" (expected one of ${[...VALID_STATUSES].join(', ')})`);
107
+ }
108
+ const current = readLatest(dir).find(p => p.id === id);
109
+ if (!current) return null;
110
+ const event = {
111
+ id,
112
+ status,
113
+ timestamp: new Date().toISOString(),
114
+ ...extras,
115
+ };
116
+ appendEvent(event, dir);
117
+ return { ...current, ...event };
118
+ }
119
+
120
+ function listByStatus(status = null, dir = DEFAULT_DIR) {
121
+ const all = readLatest(dir);
122
+ if (!status) return all;
123
+ return all.filter(p => p.status === status);
124
+ }
125
+
126
+ function findById(id, dir = DEFAULT_DIR) {
127
+ return readLatest(dir).find(p => p.id === id) || null;
128
+ }
129
+
130
+ /**
131
+ * Write the proposal's diff to its target_path (relative to repoRoot).
132
+ * Defensive: refuses to overwrite an existing file unless overwrite=true.
133
+ *
134
+ * @returns { written, path } on success
135
+ * @throws on conflict / write failure
136
+ */
137
+ function applyProposalToDisk(proposal, { repoRoot, overwrite = false } = {}) {
138
+ if (!proposal?.proposal?.diff || !proposal.proposal.target_path) {
139
+ throw new Error('applyProposalToDisk: proposal lacks diff or target_path');
140
+ }
141
+ if (!repoRoot) throw new Error('applyProposalToDisk: repoRoot is required');
142
+ const abs = path.resolve(repoRoot, proposal.proposal.target_path);
143
+ if (fs.existsSync(abs) && !overwrite) {
144
+ throw new Error(`applyProposalToDisk: refusing to overwrite existing file at ${abs} (pass overwrite=true)`);
145
+ }
146
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
147
+ fs.writeFileSync(abs, proposal.proposal.diff);
148
+ return { written: true, path: abs };
149
+ }
150
+
151
+ module.exports = {
152
+ DEFAULT_DIR,
153
+ PROPOSALS_FILE,
154
+ VALID_STATUSES,
155
+ appendProposal,
156
+ setStatus,
157
+ listByStatus,
158
+ findById,
159
+ readAll,
160
+ readLatest,
161
+ applyProposalToDisk,
162
+ };
@@ -0,0 +1,219 @@
1
+ // scripts/evolve/stats.js
2
+ //
3
+ // Phase 3.4 — Self-evolving memory: signal recording.
4
+ //
5
+ // Two signal streams are tracked, both purely local, zero telemetry:
6
+ //
7
+ // 1) reuse signals — memory IDs surfaced by auto-recall, counted
8
+ // per memory + per session. High counts → the
9
+ // memory is being repeatedly useful → candidate
10
+ // for promotion to a skill.
11
+ //
12
+ // 2) routing misses — substantive UserPromptSubmit prompts where
13
+ // memory recall returned NOTHING. Clustered later
14
+ // by tokens to surface "we keep getting asked
15
+ // about X but have no agent / skill / memory for it".
16
+ //
17
+ // Storage layout (default ${HOME}/.kodelyth/evolve/):
18
+ // reuse.json { byMemory: { id: { count, sessions[], lastSurfaced } } }
19
+ // routing-misses.jsonl append-only — one prompt per line
20
+ //
21
+ // Override the directory with $KODELYTH_EVOLVE_DIR.
22
+ //
23
+ // Pure functions — every function takes its dir explicitly. No global state.
24
+ // Safe to call from a hook: every public function swallows internal errors
25
+ // and returns either a sane default or `false`/`null`. Hooks must never crash.
26
+ 'use strict';
27
+
28
+ const fs = require('fs');
29
+ const os = require('os');
30
+ const path = require('path');
31
+ const crypto = require('crypto');
32
+
33
+ const DEFAULT_DIR = process.env.KODELYTH_EVOLVE_DIR
34
+ || path.join(os.homedir(), '.kodelyth', 'evolve');
35
+
36
+ const REUSE_FILE = 'reuse.json';
37
+ const MISSES_FILE = 'routing-misses.jsonl';
38
+
39
+ // ── helpers ──────────────────────────────────────────────────────────────────
40
+
41
+ function ensureDir(dir) {
42
+ try {
43
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
44
+ return true;
45
+ } catch { return false; }
46
+ }
47
+
48
+ function safeReadJson(p, fallback) {
49
+ try {
50
+ if (!fs.existsSync(p)) return fallback;
51
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
52
+ } catch { return fallback; }
53
+ }
54
+
55
+ function safeWriteJson(p, data) {
56
+ try {
57
+ fs.writeFileSync(p, JSON.stringify(data, null, 2));
58
+ return true;
59
+ } catch { return false; }
60
+ }
61
+
62
+ function safeAppendLine(p, line) {
63
+ try {
64
+ fs.appendFileSync(p, line.replace(/\n+$/, '') + '\n');
65
+ return true;
66
+ } catch { return false; }
67
+ }
68
+
69
+ function tokenize(text) {
70
+ if (!text) return [];
71
+ return String(text)
72
+ .toLowerCase()
73
+ .replace(/[^a-z0-9_\-/.\s]/g, ' ')
74
+ .split(/\s+/)
75
+ .filter(Boolean);
76
+ }
77
+
78
+ function hashPrompt(prompt) {
79
+ return crypto.createHash('sha256').update(String(prompt)).digest('hex').slice(0, 12);
80
+ }
81
+
82
+ // ── reuse signals ────────────────────────────────────────────────────────────
83
+
84
+ function emptyReuse() { return { byMemory: {}, lastUpdated: null }; }
85
+
86
+ function readReuse(dir = DEFAULT_DIR) {
87
+ return safeReadJson(path.join(dir, REUSE_FILE), emptyReuse());
88
+ }
89
+
90
+ /**
91
+ * Record that a memory was surfaced to the user.
92
+ * Idempotent per (memoryId, sessionId): if the same memory has already
93
+ * been counted this session, count is NOT bumped again. This matches the
94
+ * existing auto-recall semantics ("never re-surface the same memory twice
95
+ * in a session").
96
+ *
97
+ * @returns true on success, false on any error (never throws).
98
+ */
99
+ function recordSurface({ memoryId, sessionId, projectRoot, timestamp } = {}, dir = DEFAULT_DIR) {
100
+ if (!memoryId) return false;
101
+ if (!ensureDir(dir)) return false;
102
+ const data = readReuse(dir);
103
+ const ts = timestamp || new Date().toISOString();
104
+ const sid = String(sessionId || 'unknown');
105
+
106
+ const cur = data.byMemory[memoryId] || {
107
+ count: 0,
108
+ sessions: [],
109
+ projects: [],
110
+ firstSurfaced: ts,
111
+ lastSurfaced: ts,
112
+ };
113
+
114
+ if (!cur.sessions.includes(sid)) {
115
+ cur.count += 1;
116
+ cur.sessions.push(sid);
117
+ }
118
+ if (projectRoot && !cur.projects.includes(projectRoot)) {
119
+ cur.projects.push(projectRoot);
120
+ }
121
+ cur.lastSurfaced = ts;
122
+
123
+ data.byMemory[memoryId] = cur;
124
+ data.lastUpdated = ts;
125
+ return safeWriteJson(path.join(dir, REUSE_FILE), data);
126
+ }
127
+
128
+ function getReuseStats(dir = DEFAULT_DIR) {
129
+ const data = readReuse(dir);
130
+ const entries = Object.entries(data.byMemory).map(([id, v]) => ({ id, ...v }));
131
+ entries.sort((a, b) => b.count - a.count);
132
+ return {
133
+ total_memories_tracked: entries.length,
134
+ total_surfaces: entries.reduce((s, e) => s + e.count, 0),
135
+ last_updated: data.lastUpdated,
136
+ entries,
137
+ };
138
+ }
139
+
140
+ // ── routing miss signals ─────────────────────────────────────────────────────
141
+
142
+ function readMisses(dir = DEFAULT_DIR) {
143
+ const p = path.join(dir, MISSES_FILE);
144
+ if (!fs.existsSync(p)) return [];
145
+ try {
146
+ return fs.readFileSync(p, 'utf8')
147
+ .split('\n')
148
+ .filter(Boolean)
149
+ .map(line => { try { return JSON.parse(line); } catch { return null; } })
150
+ .filter(Boolean);
151
+ } catch { return []; }
152
+ }
153
+
154
+ /**
155
+ * Record a substantive prompt where memory recall returned nothing.
156
+ * Hook layer decides what counts as "substantive" — this function is dumb
157
+ * persistence. Stores the prompt itself (capped) plus tokens for clustering.
158
+ *
159
+ * @returns true on success, false on any error (never throws).
160
+ */
161
+ function recordRoutingMiss({ prompt, sessionId, projectRoot, timestamp } = {}, dir = DEFAULT_DIR) {
162
+ if (!prompt || typeof prompt !== 'string') return false;
163
+ if (!ensureDir(dir)) return false;
164
+
165
+ // Cap stored prompt to avoid log bloat from accidental paste of huge files.
166
+ const trimmed = prompt.slice(0, 1000);
167
+ const entry = {
168
+ hash: hashPrompt(trimmed),
169
+ prompt: trimmed,
170
+ tokens: tokenize(trimmed).slice(0, 32),
171
+ session_id: String(sessionId || 'unknown'),
172
+ project: projectRoot || null,
173
+ recorded_at: timestamp || new Date().toISOString(),
174
+ };
175
+ return safeAppendLine(path.join(dir, MISSES_FILE), JSON.stringify(entry));
176
+ }
177
+
178
+ function getRoutingMissStats(dir = DEFAULT_DIR) {
179
+ const all = readMisses(dir);
180
+ const byHash = new Map();
181
+ for (const m of all) {
182
+ const cur = byHash.get(m.hash) || { hash: m.hash, count: 0, samples: [], firstSeen: m.recorded_at, lastSeen: m.recorded_at, tokens: m.tokens };
183
+ cur.count += 1;
184
+ if (cur.samples.length < 3) cur.samples.push(m.prompt);
185
+ cur.lastSeen = m.recorded_at;
186
+ byHash.set(m.hash, cur);
187
+ }
188
+ const entries = [...byHash.values()].sort((a, b) => b.count - a.count);
189
+ return {
190
+ total_misses: all.length,
191
+ unique_prompts: entries.length,
192
+ entries,
193
+ };
194
+ }
195
+
196
+ // ── reset (test helper) ──────────────────────────────────────────────────────
197
+
198
+ function resetAll(dir = DEFAULT_DIR) {
199
+ try {
200
+ if (fs.existsSync(path.join(dir, REUSE_FILE))) fs.unlinkSync(path.join(dir, REUSE_FILE));
201
+ if (fs.existsSync(path.join(dir, MISSES_FILE))) fs.unlinkSync(path.join(dir, MISSES_FILE));
202
+ return true;
203
+ } catch { return false; }
204
+ }
205
+
206
+ module.exports = {
207
+ DEFAULT_DIR,
208
+ REUSE_FILE,
209
+ MISSES_FILE,
210
+ recordSurface,
211
+ readReuse,
212
+ getReuseStats,
213
+ recordRoutingMiss,
214
+ readMisses,
215
+ getRoutingMissStats,
216
+ resetAll,
217
+ // exposed for tests
218
+ _internals: { tokenize, hashPrompt },
219
+ };