OverviewFilesCommitsIssuesDocsReleasesPeers

knowledge-ratchet commits / 6382056

Track the AtomSpace knowledge layer
This layer has been running in production via .claude/settings.json hooks (SessionStart, UserPromptSubmit, PostToolUse, Stop) without ever being under version control. Committing it as-is, before any changes, so the pending cogserver.js rewrite has a baseline to diff against. Contents: the cogserver TCP client, the ingest/stream hooks that push sessions into the AtomSpace, the query layer used by preflight, and the rule/metric derivation scripts. KNOWN DEFECT, committed unchanged on purpose: cogserver.js opens a NEW TCP CONNECTION PER SCHEME EXPRESSION, and isCogServerUp() burns an extra probe socket per caller. CogServer leaks an 8MB pthread stack per accepted connection (NetworkServer.cc:169 never joins its handler threads), so this drove ~9 conn/s and ~5.3 GB of leaked stacks, contributing to a host freeze on 2026-08-11. The fix is a single persistent connection with a FIFO queue -- the queue is mandatory, since query.js:313-315 and 468-475 call evalScheme under Promise.all and would otherwise scramble replies on a shared socket. connection-reuse.test.js (bbf0990) already covers both halves and is RED. Refs: atomspace/hypercore-kb#2, atomspace/opencog-cogserver#1 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6382056ecf3098128be83ec83d859b113ae4f8b4
by plankatron <fleeky@gmail.com> · 2026-08-12T02:26:54.000Z

Changed files (30) +6699 -0

atomspace/README.md +65 -0
……@@ -0,0 +1,65 @@
1# ratchet-brain
2
3Meta-cognitive layer for the knowledge ratchet — codetree structure + ratchet
4correction history loaded into AtomSpace as a live queryable graph.
5
6**Not a general-purpose library.** Ratchet-specific. If we ever need the
7generic pieces (CogServer client, schema helpers, graph viz) in another
8project, extract them then.
9
10## What it does
11
121. **Ingest** — codetree scan + ratchet data (loops, rules, project-maps) into AtomSpace
132. **Wisdom scoring** — ranks rules/meta-atoms by evidence × referent_health × breadth
143. **Contrast-based rules** — meta-cognition atoms from success/failure diff
154. **Attention routing** — on every UserPromptSubmit, inject top-k relevant rules (budget-capped)
165. **Live observability** — stream corrections/edits into AtomSpace in real-time
176. **Graph visualization** — `http://localhost:9077/graph` — D3 force-directed view
187. **Rule-fire logging** — every hook firing is logged for utility tracking
19
20## File map
21
22| File | Purpose | Generic? |
23|---|---|---|
24| `cogserver.js` | TCP client for CogServer | Yes |
25| `schema.js` | EdgeLink/BondNode helpers | Yes |
26| `graph-export.js` / `graph.html` | D3 visualization | Yes |
27| `fire-log.js` | Append-only fire logger | Yes |
28| `ingest-codetree.js` | codetree JSON → atoms | Ratchet-specific (via codetree) |
29| `ingest-ratchet.js` | ratchet JSON → atoms | Ratchet-specific |
30| `ingest.js` | Combined ingest + classify + session-counts | Ratchet-specific |
31| `query.js` | blastRadius, contrastGuidance, liveSessionContext, etc. | Ratchet-specific |
32| `contrast-rules.js` | Meta-cognition from success/failure | Ratchet-specific |
33| `wisdom-score.js` | 3-dimensional wisdom metric | Semi-generic |
34| `attention-router.js` | UserPromptSubmit hook | Ratchet-specific |
35| `stream-correction.js` | Async correction ingestion | Ratchet-specific |
36| `stream-tool.js` | Async edit ingestion | Ratchet-specific |
37| `session-start.js` | SessionStart hook (CogServer lifecycle) | Ratchet-specific |
38| `classify-files.js` | Language/domain/role tagging | Semi-generic |
39
40## Not to be confused with
41
42**atomspace-recon** (`opencog-kb/atomspace-recon/`) — pure Scheme/Atomese
43implementation of ReCoN agent control networks. Different codebase, different
44purpose, currently dormant (proof-of-concept, not in production use).
45
46Both use CogServer and the same EdgeLink/BondNode schema conventions.
47They coexist in the same AtomSpace but do not depend on each other.
48
49## Principles
50
511. **Never delete atoms** — staleness is a Value, not a fate
522. **Confidence is graduated** — utility/referent_health/breadth compose
533. **Live observability over batch** — stream events in real-time
544. **Token budget everywhere** — briefings are capped to prevent context bloat
555. **Graceful fallback** — everything degrades to JSON when CogServer is down
56
57## Status
58
59Live and integrated into knowledge-ratchet hooks (SessionStart, UserPromptSubmit,
60PreToolUse, PostToolUse, Stop).
61
62- ~17K atoms (codetree + ratchet data)
63- 340 rule/meta atoms with wisdom scores
64- 73 per-loop contrasts, 12 cross-loop patterns
65- ~25ms preflight latency added by AtomSpace queries
atomspace/advanced-metrics.js +311 -0
……@@ -0,0 +1,311 @@
1#!/usr/bin/env node
2
3/**
4 * advanced-metrics.js — Compute derived metrics from turn-level detail
5 *
6 * Metrics:
7 * 1. time-to-correction — avg turns from bad turn to next correction
8 * 2. per-domain per-turn score — quality broken down by language/domain
9 * 3. context saturation — cumulative message/token growth per session
10 * 4. plan adherence — TODO completion vs creation (when tasks are tracked)
11 *
12 * Output: enriches sessions with derived metrics, writes to data/advanced-metrics.json
13 */
14
15const fs = require('fs')
16const path = require('path')
17
18const DATA_DIR = path.join(__dirname, '..', 'data')
19
20function readJSON (name) {
21 const file = path.join(DATA_DIR, name)
22 if (!fs.existsSync(file)) return null
23 return JSON.parse(fs.readFileSync(file, 'utf8'))
24}
25
26/**
27 * Infer language/domain of a file path (subset of classify-files logic).
28 */
29function classify (file) {
30 if (!file) return { lang: 'unknown', domain: 'unknown' }
31 let lang = 'unknown'
32 if (/\.(cpp|cc|h|hpp)$/.test(file)) lang = 'cpp'
33 else if (/\.gd$/.test(file)) lang = 'gdscript'
34 else if (/\.(glsl|gdshader|shader)$/.test(file)) lang = 'glsl'
35 else if (/\.py$/.test(file)) lang = 'python'
36 else if (/\.js$/.test(file)) lang = 'javascript'
37 else if (/\.json$/.test(file)) lang = 'json'
38 else if (/\.md$/.test(file)) lang = 'markdown'
39 else if (/\.scm$/.test(file)) lang = 'scheme'
40
41 let domain = 'unknown'
42 if (file.includes('/code/godot/')) domain = 'godot'
43 else if (file.includes('/memory/') || file.includes('MEMORY.md')) domain = 'claude-memory'
44 else if (file.includes('.claude/')) domain = 'claude-config'
45 else if (file.includes('knowledge-ratchet')) domain = 'ratchet'
46 else if (file.includes('hyper-fleeky/') || /(pear-git|hypernote|hyper-|wirecore|codetree|trust-map)/.test(file)) domain = 'p2p-stack'
47
48 return { lang, domain }
49}
50
51/**
52 * 1. Time-to-correction per session.
53 * For each turn that scored low OR had a build error, find how many turns
54 * until the next correction (if any).
55 */
56function timeToCorrection (session) {
57 const turns = session.turns || []
58 if (turns.length === 0) return { samples: 0, avgTurns: null, quickRecoveryRate: null }
59
60 // Find "bad turn" indices — low score or build errors
61 const badIndices = []
62 for (let i = 0; i < turns.length; i++) {
63 const t = turns[i]
64 if (t.score < 0.5 || (t.sensors && t.sensors.buildErrors < 0.5)) badIndices.push(i)
65 }
66
67 // For each bad turn, time to next correction
68 const distances = []
69 for (const badIdx of badIndices) {
70 for (let j = badIdx + 1; j < turns.length; j++) {
71 if (turns[j].isCorrection) {
72 distances.push(j - badIdx)
73 break
74 }
75 }
76 }
77
78 if (distances.length === 0) return { samples: badIndices.length, avgTurns: null, quickRecoveryRate: null }
79
80 const avgTurns = distances.reduce((a, b) => a + b, 0) / distances.length
81 const quickRecoveryRate = distances.filter(d => d <= 2).length / distances.length
82
83 return {
84 samples: distances.length,
85 avgTurns: Math.round(avgTurns * 10) / 10,
86 quickRecoveryRate: Math.round(quickRecoveryRate * 100) / 100
87 }
88}
89
90/**
91 * 2. Per-domain per-turn score.
92 * Turns that touched a specific domain contribute to that domain's avg score.
93 */
94function perDomainScore (session) {
95 const turns = session.turns || []
96 const byDomain = {}
97
98 for (const t of turns) {
99 if (!t.editFiles || t.editFiles.length === 0) continue
100 // Classify each edited file's domain
101 const domains = new Set()
102 for (const f of t.editFiles) {
103 const { domain } = classify(f)
104 domains.add(domain)
105 }
106 for (const d of domains) {
107 if (!byDomain[d]) byDomain[d] = { totalScore: 0, count: 0 }
108 byDomain[d].totalScore += t.score
109 byDomain[d].count++
110 }
111 }
112
113 const result = {}
114 for (const [d, stats] of Object.entries(byDomain)) {
115 if (stats.count < 2) continue // need ≥2 turns for signal
116 result[d] = {
117 avgScore: Math.round((stats.totalScore / stats.count) * 1000) / 1000,
118 turnCount: stats.count
119 }
120 }
121 return result
122}
123
124/**
125 * 3. Context saturation — cumulative user-message length as proxy.
126 * Long sessions with lots of back-and-forth saturate context.
127 */
128function contextSaturation (session) {
129 const turns = session.turns || []
130 if (turns.length === 0) return { turns: 0, totalToolCalls: 0, saturationLevel: 'light' }
131
132 // Better proxy: combine turn count × avg tool calls per turn = total activity
133 // This correlates with context consumption more than user message length
134 const totalToolCalls = turns.reduce((s, t) => s + (t.toolCalls || 0), 0)
135 const toolsPerTurn = totalToolCalls / turns.length
136
137 // Cache hit rate (from sensors) = context being reused (healthy)
138 // Low cache hit = fresh context (consuming quota)
139 const cacheHits = turns.map(t => (t.sensors && t.sensors.cacheHitRate) || 0.5)
140 const avgCacheHit = cacheHits.reduce((a, b) => a + b, 0) / cacheHits.length
141
142 // Saturation = long session × many tools × low cache hits
143 // Based on actual session distributions we've seen:
144 // Light: <30 turns OR <100 tool calls
145 // Medium: 30-80 turns AND 100-500 tool calls
146 // Heavy: >80 turns OR >500 tool calls
147 let saturationLevel = 'light'
148 if (turns.length > 80 || totalToolCalls > 500) saturationLevel = 'heavy'
149 else if (turns.length > 30 && totalToolCalls > 100) saturationLevel = 'medium'
150
151 return {
152 turns: turns.length,
153 totalToolCalls,
154 toolsPerTurn: Math.round(toolsPerTurn * 10) / 10,
155 avgCacheHit: Math.round(avgCacheHit * 100) / 100,
156 saturationLevel
157 }
158}
159
160/**
161 * 4. Plan adherence — rough proxy via TaskCreate/TaskUpdate mentions.
162 * Counts "TaskCreate" mentions in tool calls vs "status.*completed" mentions.
163 */
164function planAdherence (session) {
165 const turns = session.turns || []
166 let created = 0
167 let completed = 0
168 let deleted = 0
169
170 // Plan adherence requires raw tool-call detail not present in turn summary.
171 // Use turn text as a weak proxy: count TaskCreate/TaskUpdate mentions in text.
172 // This is noisy but gives a directional signal.
173 for (const t of turns) {
174 const text = (t.text || '')
175 const tcMatches = (text.match(/TaskCreate/g) || []).length
176 const compMatches = (text.match(/status.*completed|TaskUpdate.*completed/gi) || []).length
177 const delMatches = (text.match(/status.*deleted/gi) || []).length
178 created += tcMatches
179 completed += compMatches
180 deleted += delMatches
181 }
182
183 if (created === 0) return { created: 0, completed: 0, deleted: 0, adherence: null }
184
185 // Adherence: completed / created (ignoring deleted — those were abandoned)
186 const adherence = created > 0 ? completed / created : null
187 return {
188 created,
189 completed,
190 deleted,
191 adherence: adherence != null ? Math.round(adherence * 100) / 100 : null
192 }
193}
194
195async function main () {
196 const detail = readJSON('fused-turns-detail.json')
197 const fused = readJSON('fused-scores.json')
198 if (!detail) { console.log('No fused-turns-detail.json'); process.exit(1) }
199 if (!fused) { console.log('No fused-scores.json'); process.exit(1) }
200
201 const detailSessions = detail.sessions || detail
202 const fusedSessionsById = {}
203 for (const s of (fused.sessions || [])) fusedSessionsById[s.sessionId] = s
204
205 const results = []
206 const aggregates = {
207 timeToCorrection: { totalSamples: 0, totalQuick: 0, avgDistances: [] },
208 perDomain: {},
209 saturation: { light: 0, medium: 0, heavy: 0 },
210 planAdherence: { totalCreated: 0, totalCompleted: 0, sessionsWithTasks: 0 }
211 }
212
213 for (const s of detailSessions) {
214 const ttc = timeToCorrection(s)
215 const perDom = perDomainScore(s)
216 const sat = contextSaturation(s)
217 const plan = planAdherence(s)
218
219 results.push({
220 sessionId: s.sessionId,
221 date: s.date,
222 ttc, perDom, sat, plan
223 })
224
225 // Aggregate
226 if (ttc.samples > 0) {
227 aggregates.timeToCorrection.totalSamples += ttc.samples
228 if (ttc.avgTurns !== null) aggregates.timeToCorrection.avgDistances.push(ttc.avgTurns)
229 if (ttc.quickRecoveryRate !== null) aggregates.timeToCorrection.totalQuick += ttc.quickRecoveryRate * ttc.samples
230 }
231 for (const [d, stats] of Object.entries(perDom)) {
232 if (!aggregates.perDomain[d]) aggregates.perDomain[d] = { totalScore: 0, totalTurns: 0, sessionCount: 0 }
233 aggregates.perDomain[d].totalScore += stats.avgScore * stats.turnCount
234 aggregates.perDomain[d].totalTurns += stats.turnCount
235 aggregates.perDomain[d].sessionCount++
236 }
237 aggregates.saturation[sat.saturationLevel]++
238 if (plan.created > 0) {
239 aggregates.planAdherence.totalCreated += plan.created
240 aggregates.planAdherence.totalCompleted += plan.completed
241 aggregates.planAdherence.sessionsWithTasks++
242 }
243 }
244
245 // Compute aggregates
246 const agg = {
247 timeToCorrection: {
248 samples: aggregates.timeToCorrection.totalSamples,
249 avgTurnsAcrossSessions: aggregates.timeToCorrection.avgDistances.length > 0
250 ? Math.round((aggregates.timeToCorrection.avgDistances.reduce((a, b) => a + b, 0) / aggregates.timeToCorrection.avgDistances.length) * 10) / 10
251 : null,
252 quickRecoveryOverall: aggregates.timeToCorrection.totalSamples > 0
253 ? Math.round((aggregates.timeToCorrection.totalQuick / aggregates.timeToCorrection.totalSamples) * 100) / 100
254 : null
255 },
256 perDomain: Object.fromEntries(
257 Object.entries(aggregates.perDomain).map(([d, s]) => [d, {
258 avgPerTurnScore: s.totalTurns > 0 ? Math.round((s.totalScore / s.totalTurns) * 1000) / 1000 : null,
259 turnCount: s.totalTurns,
260 sessionCount: s.sessionCount
261 }])
262 ),
263 saturation: aggregates.saturation,
264 planAdherence: {
265 totalCreated: aggregates.planAdherence.totalCreated,
266 totalCompleted: aggregates.planAdherence.totalCompleted,
267 overallAdherence: aggregates.planAdherence.totalCreated > 0
268 ? Math.round((aggregates.planAdherence.totalCompleted / aggregates.planAdherence.totalCreated) * 100) / 100
269 : null,
270 sessionsWithTasks: aggregates.planAdherence.sessionsWithTasks
271 }
272 }
273
274 const output = { generated: new Date().toISOString(), aggregate: agg, sessions: results }
275 fs.writeFileSync(path.join(DATA_DIR, 'advanced-metrics.json'), JSON.stringify(output, null, 2))
276
277 // Report
278 console.log('=== Advanced Metrics ===\n')
279
280 console.log('── Time to Correction ──')
281 console.log(` Samples: ${agg.timeToCorrection.samples} bad turns with follow-up corrections`)
282 console.log(` Avg turns until correction: ${agg.timeToCorrection.avgTurnsAcrossSessions}`)
283 console.log(` Quick recovery rate (≤2 turns): ${(agg.timeToCorrection.quickRecoveryOverall * 100).toFixed(0)}%`)
284
285 console.log('\n── Per-Domain Quality ──')
286 const sortedDom = Object.entries(agg.perDomain).sort((a, b) => b[1].turnCount - a[1].turnCount)
287 for (const [d, stats] of sortedDom) {
288 console.log(` ${d.padEnd(15)} score=${stats.avgPerTurnScore} (${stats.turnCount} turns, ${stats.sessionCount} sessions)`)
289 }
290
291 console.log('\n── Context Saturation ──')
292 console.log(` Light: ${agg.saturation.light} sessions`)
293 console.log(` Medium: ${agg.saturation.medium} sessions`)
294 console.log(` Heavy: ${agg.saturation.heavy} sessions`)
295
296 console.log('\n── Plan Adherence ──')
297 if (agg.planAdherence.overallAdherence !== null) {
298 console.log(` Tasks created: ${agg.planAdherence.totalCreated}`)
299 console.log(` Tasks completed: ${agg.planAdherence.totalCompleted}`)
300 console.log(` Adherence: ${(agg.planAdherence.overallAdherence * 100).toFixed(0)}%`)
301 console.log(` Sessions using tasks: ${agg.planAdherence.sessionsWithTasks}`)
302 } else {
303 console.log(' No TaskCreate/TaskUpdate data found')
304 }
305}
306
307if (require.main === module) {
308 main().catch(err => { console.error(err.message); process.exit(1) })
309}
310
311module.exports = { timeToCorrection, perDomainScore, contextSaturation, planAdherence }
atomspace/attention-router.js +238 -0
……@@ -0,0 +1,238 @@
1#!/usr/bin/env node
2
3/**
4 * attention-router.js — Inject relevant meta-cognition BEFORE Claude acts
5 *
6 * Runs as UserPromptSubmit hook. On every user message:
7 * 1. Extract context: current cwd, files mentioned, session-edited files
8 * 2. Query AtomSpace for top-k wisdom-ranked rules matching this context
9 * 3. Output a concise briefing (≤500 tokens) as additionalContext
10 *
11 * This is Linas's "little birdie" — the attentional steering mechanism.
12 * Complements preflight-review.js (which fires on Edit/Write) by adding
13 * proactive guidance on every prompt.
14 *
15 * Fails silently if CogServer not running or AtomSpace empty.
16 */
17
18const fs = require('fs')
19const path = require('path')
20const { isCogServerUp, evalScheme } = require('./cogserver')
21const { relevantAtoms, liveSessionContext } = require('./query')
22const { logFire } = require('./fire-log')
23const { evaluateNow: evalStrategicRules } = require('./strategic-rules')
24
25const SESSION_FILE = '/tmp/claude-atomspace-session'
26const SESSION_STATE_FILE = '/tmp/claude-session-score.json'
27const TOKEN_BUDGET = 500 // hard cap on injected context size
28
29function readSessionState () {
30 try { return JSON.parse(fs.readFileSync(SESSION_STATE_FILE, 'utf8')) }
31 catch { return {} }
32}
33
34/**
35 * Extract file paths mentioned in the user's prompt.
36 * Simple heuristic: look for slash-separated paths with common extensions.
37 */
38function extractMentionedFiles (prompt) {
39 if (!prompt) return []
40 const fileRegex = /\b[\w./-]+\.(js|ts|py|cpp|cc|h|hpp|gd|glsl|scm|md|json|toml|yaml|yml|tscn|tres)\b/g
41 const matches = prompt.match(fileRegex) || []
42 return [...new Set(matches)]
43}
44
45/**
46 * Extract module name from cwd.
47 * /storage/projects/code/hypercore-kb/hyper-fleeky/MODULE/... → MODULE
48 */
49function extractModule (cwd) {
50 if (!cwd) return null
51 const m = cwd.match(/\/hyper-fleeky\/([^/]+)/)
52 if (m) return m[1]
53 const m2 = cwd.match(/\/godot\/([^/]+)/)
54 if (m2) return m2[1]
55 return null
56}
57
58/**
59 * Extract language from module (best guess based on module type).
60 */
61function inferLanguage (module) {
62 if (!module) return null
63 // Godot projects
64 if (/^(dc-terrain|proc-nodes|blender-bridge|nvidia-rtx|godot-)/.test(module)) return 'cpp'
65 if (module === 'hyper-tps') return 'gdscript'
66 // JS projects (hyper-fleeky)
67 return 'javascript'
68}
69
70/**
71 * Build the attention briefing — concise, budget-capped.
72 */
73function formatBriefing (relevant, session, module, hasMentionedFiles, strategicRules) {
74 const hasTier1 = relevant && relevant.tier1.length > 0
75 const hasTier2 = relevant && relevant.tier2.length > 0
76 const hasStrategic = strategicRules && strategicRules.length > 0
77
78 // Strategic rules fire regardless of prompt content — they're about session STATE
79 // If nothing else AND no strategic rules, be quiet
80 if (!hasTier1 && !hasTier2 && !hasStrategic) return null
81
82 const sessionInteresting = session && session.totalSessionCorrections > 2
83 // Silence only if nothing to say: no files mentioned, no session issues, no strategic rules
84 if (!hasMentionedFiles && !sessionInteresting && !hasStrategic) return null
85
86 const lines = []
87
88 // STRATEGIC RULES FIRST — these are situational warnings that matter most
89 if (hasStrategic) {
90 for (const r of strategicRules.slice(0, 2)) { // cap at 2 to respect budget
91 const marker = r.severity === 'high' ? '‼' : '⚠'
92 lines.push(`${marker} [${r.id}] ${r.message}`)
93 }
94 }
95
96 // Session context (only if there are corrections AND this is work-related)
97 if (sessionInteresting && hasMentionedFiles) {
98 lines.push(`⚠ This session: ${session.totalSessionCorrections} corrections already — slow down`)
99 }
100
101 // Tier 1: top 3 high-wisdom rules (only show if we have mentioned files)
102 if (hasTier1 && hasMentionedFiles) {
103 lines.push('Top attention signals:')
104 for (const t of relevant.tier1) {
105 if (!t.summary) continue
106 const prefix = t.wisdom >= 1.0 ? '★' : '·'
107 lines.push(` ${prefix} ${t.summary}`)
108 }
109 }
110
111 // Tier 2: abbreviated next 2
112 if (hasTier2 && hasMentionedFiles && lines.length > 0) {
113 lines.push(`Also relevant: ${relevant.tier2.length} more rules available`)
114 }
115
116 if (lines.length === 0) return null
117
118 const text = lines.join('\n')
119 const tokens = Math.ceil(text.length / 4)
120
121 // Hard budget cap
122 if (tokens > TOKEN_BUDGET) {
123 // Keep tier 1 only, drop tier 2
124 const shortened = lines.slice(0, 4).join('\n')
125 return shortened.length > TOKEN_BUDGET * 4
126 ? shortened.slice(0, TOKEN_BUDGET * 4) + '...'
127 : shortened
128 }
129
130 return text
131}
132
133async function main () {
134 // Read prompt input
135 let input = ''
136 for await (const chunk of process.stdin) input += chunk
137
138 let data
139 try { data = JSON.parse(input) } catch { process.exit(0) }
140
141 const prompt = data.prompt || data.user_prompt || data.message || ''
142 if (!prompt || prompt.length < 3) process.exit(0)
143
144 // Skip analyzer prompts (prevent feedback loop)
145 if (prompt.includes('You are analyzing a user') ||
146 prompt.includes('REGEX CLASSIFIED AS') ||
147 prompt.includes('respond with EXACTLY one line')) {
148 process.exit(0)
149 }
150
151 // CogServer / session must be ready
152 if (!await isCogServerUp()) process.exit(0)
153 let sessionId = null
154 try { sessionId = fs.readFileSync(SESSION_FILE, 'utf8').trim() } catch {}
155 if (!sessionId) process.exit(0)
156
157 // Build context
158 const state = readSessionState()
159 const cwd = state._cwd || process.cwd()
160 const module = extractModule(cwd)
161 const language = inferLanguage(module)
162
163 // Files: mentioned in prompt + recently edited this session
164 const mentioned = extractMentionedFiles(prompt)
165 const edited = state.editedFiles || state._lastTurnEdits || []
166 const files = [...new Set([...mentioned, ...edited])].slice(0, 10)
167
168 // Resolve bare filenames to known file atoms (so dc_terrain.cpp finds dc-terrain/dc_terrain.cpp)
169 const resolvedFiles = []
170 for (const f of files) {
171 if (f.includes('/')) {
172 resolvedFiles.push(f)
173 continue
174 }
175 // Bare filename — query AtomSpace for file: atoms ending with this filename
176 try {
177 const matchStr = await evalScheme(
178 `(string-join (filter (lambda (n) (string-suffix? "/${f.replace(/"/g, '\\"')}" n)) (map cog-name (cog-get-atoms (quote ConceptNode)))) "|")`)
179 const hits = (matchStr || '').replace(/^"|"$/g, '').split('|').filter(n => n.startsWith('file:'))
180 for (const h of hits.slice(0, 3)) {
181 resolvedFiles.push(h.replace('file:', ''))
182 }
183 if (hits.length === 0) resolvedFiles.push(f) // keep original as fallback
184 } catch {
185 resolvedFiles.push(f)
186 }
187 }
188
189 // Query AtomSpace for relevant atoms
190 let relevant = null
191 try {
192 relevant = await relevantAtoms({ cwd, files: resolvedFiles, module, language })
193 } catch {}
194
195 // Also grab live session context for a sample file (if any)
196 let session = null
197 if (files.length > 0) {
198 try { session = await liveSessionContext(files[0]) } catch {}
199 }
200
201 // Evaluate strategic situational rules (based on live session state)
202 let strategicRules = []
203 try { strategicRules = evalStrategicRules() } catch {}
204
205 const briefing = formatBriefing(relevant, session, module, mentioned.length > 0, strategicRules)
206 if (!briefing) process.exit(0)
207
208 // Log each fired rule/meta atom for utility tracking + live dashboard
209 if (relevant) {
210 const promptExcerpt = prompt.slice(0, 200)
211 const triggerFiles = mentioned.slice(0, 3).join(', ')
212 for (const tier of [relevant.tier1, relevant.tier2]) {
213 for (const t of (tier || [])) {
214 logFire({
215 session: sessionId,
216 hook: 'attention-router',
217 atom: t.atom,
218 wisdom: t.wisdom,
219 context: promptExcerpt,
220 trigger: triggerFiles || module
221 })
222 }
223 }
224 }
225
226 // Output as additionalContext (Claude Code hook convention)
227 const output = {
228 hookSpecificOutput: {
229 hookEventName: 'UserPromptSubmit',
230 additionalContext: '[AtomSpace attention]\n' + briefing
231 }
232 }
233
234 process.stdout.write(JSON.stringify(output))
235 process.exit(0)
236}
237
238main().catch(() => process.exit(0))
atomspace/chain-analysis.js +287 -0
……@@ -0,0 +1,287 @@
1#!/usr/bin/env node
2
3/**
4 * chain-analysis.js — Analyze continuation chains as "implementations"
5 *
6 * Each chain = sequence of sessions linked by 'continues' edges.
7 * Represents ONE body of work spanning multiple sessions.
8 *
9 * For each chain:
10 * Speed = approvals per turn across all chain sessions
11 * Complexity = files × correction-density × turns
12 * Productivity = speed / complexity (how fast on hard stuff)
13 * Throughput = approvals per hour (wall-clock)
14 *
15 * Output: data/chain-analysis.json — per-chain metrics + aggregates
16 */
17
18const fs = require('fs')
19const path = require('path')
20
21const DATA_DIR = path.join(__dirname, '..', 'data')
22
23function readJSON (name) {
24 const file = path.join(DATA_DIR, name)
25 if (!fs.existsSync(file)) return null
26 return JSON.parse(fs.readFileSync(file, 'utf8'))
27}
28
29/**
30 * Build chains from sessions.
31 * Chain: sequence of sessions sharing gitBranch+cwd, connected by time proximity.
32 */
33function buildChains (sessions) {
34 // Sort by timestamp
35 const sorted = [...sessions].sort((a, b) =>
36 new Date(a.timestamp || a.date).getTime() - new Date(b.timestamp || b.date).getTime())
37
38 // Group into chains: same gitBranch + cwd, within 7 days gap
39 const chains = []
40 const sessionToChain = new Map()
41
42 for (const s of sorted) {
43 const ts = new Date(s.timestamp || s.date).getTime()
44 // Find an existing chain this could belong to (most recent matching)
45 let matched = null
46 for (let i = chains.length - 1; i >= 0; i--) {
47 const chain = chains[i]
48 const lastSession = chain.sessions[chain.sessions.length - 1]
49 if (lastSession.gitBranch !== s.gitBranch) continue
50 if (lastSession.cwd !== s.cwd) continue
51 const lastTs = new Date(lastSession.timestamp || lastSession.date).getTime()
52 const gapHours = (ts - lastTs) / 1000 / 3600
53 if (gapHours <= 24 * 7) {
54 matched = chain
55 break
56 }
57 }
58
59 if (matched) {
60 matched.sessions.push(s)
61 } else {
62 chains.push({ sessions: [s] })
63 }
64 sessionToChain.set(s.sessionId, chains.length - 1)
65 }
66
67 return chains
68}
69
70/**
71 * Compute chain-level metrics.
72 */
73function analyzeChain (chain, loopsBySession) {
74 const sessions = chain.sessions
75 const first = sessions[0]
76 const last = sessions[sessions.length - 1]
77
78 // Aggregates across all chain sessions
79 let totalTurns = 0
80 let totalCorrections = 0
81 let totalApprovals = 0
82 let totalToolCalls = 0
83 const uniqueFiles = new Set()
84 const uniqueModules = new Set()
85 const loopTypes = {}
86 let resolvedLoops = 0
87 let unresolvedLoops = 0
88 const territories = new Set()
89
90 for (const s of sessions) {
91 totalTurns += s.turns || 0
92 totalCorrections += s.corrections || 0
93 totalApprovals += s.approvals || 0
94 totalToolCalls += s.totalToolCalls || 0
95
96 // Loops for this session
97 const loops = loopsBySession[s.sessionId] || []
98 for (const l of loops) {
99 if (l.resolved) resolvedLoops++
100 else unresolvedLoops++
101 loopTypes[l.type] = (loopTypes[l.type] || 0) + 1
102 for (const f of (l.filesTried || [])) {
103 uniqueFiles.add(f)
104 const mod = normalizeToModule(f)
105 if (mod) uniqueModules.add(mod)
106 }
107 for (const f of ((l.resolution || {}).files || [])) {
108 uniqueFiles.add(f)
109 const mod = normalizeToModule(f)
110 if (mod) uniqueModules.add(mod)
111 }
112 }
113 }
114
115 // Duration (wall clock)
116 const firstTs = new Date(first.timestamp || first.date).getTime()
117 const lastTs = new Date(last.timestamp || last.date).getTime()
118 const durationHours = (lastTs - firstTs) / 1000 / 3600
119
120 // Speed metrics
121 const approvalsPerTurn = totalTurns > 0 ? totalApprovals / totalTurns : 0
122 const approvalsPerHour = durationHours > 0 ? totalApprovals / durationHours : null
123 const correctionDensity = totalTurns > 0 ? totalCorrections / totalTurns : 0
124
125 // Complexity: multi-dimensional
126 // Higher files = broader surface
127 // Higher correction density = harder problems
128 // Cross-module = architecturally complex
129 const complexity = {
130 turns: totalTurns,
131 files: uniqueFiles.size,
132 modules: uniqueModules.size,
133 correctionDensity: Math.round(correctionDensity * 1000) / 1000,
134 toolCallsPerTurn: totalTurns > 0 ? Math.round((totalToolCalls / totalTurns) * 10) / 10 : 0,
135 // Composite score — normalize each and combine
136 score: Math.round(
137 (Math.log(1 + uniqueFiles.size) * 2 +
138 Math.log(1 + uniqueModules.size) * 3 +
139 correctionDensity * 10 +
140 Math.log(1 + totalTurns) * 1) * 10
141 ) / 10
142 }
143
144 // Speed score
145 const speed = {
146 approvalsPerTurn: Math.round(approvalsPerTurn * 1000) / 1000,
147 approvalsPerHour: approvalsPerHour != null ? Math.round(approvalsPerHour * 10) / 10 : null,
148 durationHours: Math.round(durationHours * 10) / 10,
149 loopResolutionRate: (resolvedLoops + unresolvedLoops) > 0
150 ? Math.round((resolvedLoops / (resolvedLoops + unresolvedLoops)) * 100) / 100
151 : null
152 }
153
154 // Productivity = speed / complexity
155 // High productivity = fast progress on hard work
156 const productivity = complexity.score > 0
157 ? Math.round((approvalsPerTurn * 100 / complexity.score) * 1000) / 1000
158 : null
159
160 return {
161 chainId: first.sessionId,
162 firstDate: first.date,
163 lastDate: last.date,
164 sessionCount: sessions.length,
165 module: inferChainModule(uniqueModules),
166 speed,
167 complexity,
168 productivity,
169 loopTypes: Object.keys(loopTypes).length > 0
170 ? Object.fromEntries(Object.entries(loopTypes).sort((a, b) => b[1] - a[1]).slice(0, 3))
171 : {},
172 unresolvedLoops,
173 resolvedLoops
174 }
175}
176
177function normalizeToModule (f) {
178 if (!f) return null
179 const normalized = f
180 .replace(/^\/storage\/projects\/code\/hypercore-kb\/hyper-fleeky\//, '')
181 .replace(/^\/storage\/projects\/code\/hypercore-kb\//, '')
182 .replace(/^\/storage\/projects\/code\//, '')
183 const parts = normalized.split('/')
184 if (parts.length > 0 && !parts[0].startsWith('.') && !parts[0].startsWith('/home')) {
185 return parts[0]
186 }
187 return null
188}
189
190function inferChainModule (uniqueModules) {
191 // Most common module by count
192 const arr = [...uniqueModules]
193 if (arr.length === 0) return 'unknown'
194 if (arr.length === 1) return arr[0]
195 return arr.slice(0, 3).join('+')
196}
197
198async function main () {
199 const fused = readJSON('fused-scores.json')
200 const loops = readJSON('loops.json')
201 if (!fused) { console.log('No fused-scores'); process.exit(1) }
202
203 const sessions = fused.sessions || []
204
205 // Index loops by session
206 const loopsBySession = {}
207 for (const l of (loops?.loops || [])) {
208 if (!loopsBySession[l.sessionId]) loopsBySession[l.sessionId] = []
209 loopsBySession[l.sessionId].push(l)
210 }
211
212 const chains = buildChains(sessions)
213 console.log(`Analyzing ${chains.length} chains from ${sessions.length} sessions...`)
214
215 const analyzed = chains.map(c => analyzeChain(c, loopsBySession))
216
217 // Sort by complexity descending
218 analyzed.sort((a, b) => b.complexity.score - a.complexity.score)
219
220 const output = {
221 generated: new Date().toISOString(),
222 totalChains: chains.length,
223 chains: analyzed
224 }
225 fs.writeFileSync(path.join(DATA_DIR, 'chain-analysis.json'), JSON.stringify(output, null, 2))
226
227 // Report
228 console.log('\n── Top 10 Most Complex Implementations ──')
229 console.log(' complexity speed productivity sessions module dates')
230 for (const c of analyzed.slice(0, 10)) {
231 console.log(
232 ` ${String(c.complexity.score).padStart(8)} ` +
233 `${String(c.speed.approvalsPerTurn).padEnd(8)} ` +
234 `${String(c.productivity || '-').padEnd(12)} ` +
235 `${String(c.sessionCount).padStart(3)} ` +
236 `${c.module.padEnd(20).slice(0, 20)} ` +
237 `${c.firstDate}→${c.lastDate}`
238 )
239 }
240
241 console.log('\n── Top 10 Most Productive (speed/complexity) ──')
242 const byProd = [...analyzed]
243 .filter(c => c.productivity != null && c.complexity.score > 5)
244 .sort((a, b) => b.productivity - a.productivity)
245 .slice(0, 10)
246 for (const c of byProd) {
247 console.log(
248 ` prod=${String(c.productivity).padEnd(8)} ` +
249 `complexity=${String(c.complexity.score).padStart(6)} ` +
250 `speed=${String(c.speed.approvalsPerTurn).padEnd(7)} ` +
251 `${c.module.padEnd(20).slice(0, 20)} ` +
252 `${c.firstDate}`
253 )
254 }
255
256 console.log('\n── Aggregates by module ──')
257 const byModule = {}
258 for (const c of analyzed) {
259 const primary = c.module.split('+')[0]
260 if (!byModule[primary]) byModule[primary] = { chains: 0, totalSpeed: 0, totalComplexity: 0, totalProductivity: 0, prodCount: 0 }
261 byModule[primary].chains++
262 byModule[primary].totalSpeed += c.speed.approvalsPerTurn
263 byModule[primary].totalComplexity += c.complexity.score
264 if (c.productivity != null) {
265 byModule[primary].totalProductivity += c.productivity
266 byModule[primary].prodCount++
267 }
268 }
269 console.log(' module chains avg-speed avg-complexity avg-productivity')
270 const sortedByModule = Object.entries(byModule).sort((a, b) => b[1].chains - a[1].chains)
271 for (const [mod, stats] of sortedByModule.slice(0, 10)) {
272 const avgSpeed = stats.totalSpeed / stats.chains
273 const avgComp = stats.totalComplexity / stats.chains
274 const avgProd = stats.prodCount > 0 ? (stats.totalProductivity / stats.prodCount) : null
275 console.log(
276 ` ${mod.padEnd(20).slice(0, 20)} ${String(stats.chains).padStart(3)} ` +
277 `${avgSpeed.toFixed(3)} ${avgComp.toFixed(1).padStart(6)} ` +
278 `${avgProd != null ? avgProd.toFixed(3) : '-'}`
279 )
280 }
281}
282
283if (require.main === module) {
284 main().catch(err => { console.error(err.message); process.exit(1) })
285}
286
287module.exports = { buildChains, analyzeChain }
atomspace/classify-files.js +150 -0
……@@ -0,0 +1,150 @@
1#!/usr/bin/env node
2
3/**
4 * classify-files.js — Add language, domain, and role metadata to all loop-files
5 *
6 * Disambiguates the 467 "unknown" role files by examining extensions and paths.
7 * Stores classification as Values on file atoms in AtomSpace.
8 */
9
10const { evalScheme, isCogServerUp } = require('./cogserver')
11
12function classifyFile (f) {
13 // Language from extension
14 let lang = 'unknown'
15 if (/\.(cpp|cc|h|hpp)$/.test(f)) lang = 'cpp'
16 else if (/\.gd$/.test(f)) lang = 'gdscript'
17 else if (/\.(glsl|gdshader|shader)$/.test(f)) lang = 'glsl'
18 else if (/\.py$/.test(f)) lang = 'python'
19 else if (/\.js$/.test(f)) lang = 'javascript'
20 else if (/\.json$/.test(f)) lang = 'json'
21 else if (/\.md$/.test(f)) lang = 'markdown'
22 else if (/\.scm$/.test(f)) lang = 'scheme'
23 else if (/\.(toml|yaml|yml)$/.test(f)) lang = 'config'
24 else if (/\.(tscn|tres)$/.test(f)) lang = 'godot-scene'
25
26 // Domain from path
27 let domain = 'unknown'
28 if (f.includes('/code/godot/')) domain = 'godot'
29 else if (f.includes('/memory/') || f.includes('MEMORY.md')) domain = 'claude-memory'
30 else if (f.includes('.claude/')) domain = 'claude-config'
31 else if (f.includes('knowledge-ratchet')) domain = 'ratchet'
32 else if (f.includes('hyper-fleeky/') || /^(pear-git|hypernote|hyper-|wirecore|codetree|trust-map)/.test(f)) domain = 'p2p-stack'
33
34 // Role from path patterns
35 let role = 'unknown'
36 if (/\/test\/|\.test\./.test(f)) role = 'test'
37 else if (f.includes('/src/')) role = 'src'
38 else if (f.includes('/lib/')) role = 'lib'
39 else if (f.includes('/bin/')) role = 'bin'
40 else if (/\/examples?\//.test(f)) role = 'example'
41 else if (f.includes('/docs/') || f.endsWith('.md')) role = 'docs'
42 else if (f.includes('/scenes/')) role = 'scene'
43 else if (f.includes('/shaders/') || f.includes('/addons/')) role = 'addon'
44 else if (/\.(json|toml|yaml|yml|cfg)$/.test(f)) role = 'config'
45 else if (/(index|main)\.(js|gd|cpp)$/.test(f)) role = 'entry'
46
47 return { lang, domain, role }
48}
49
50function esc (s) {
51 return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
52}
53
54async function main () {
55 if (!await isCogServerUp()) {
56 console.log('CogServer not running')
57 process.exit(1)
58 }
59
60 await evalScheme('(use-modules (srfi srfi-1))')
61
62 // Get all loop-files
63 const loopFileStr = await evalScheme(
64 '(string-join (delete-duplicates (map (lambda (e) (cog-name (cog-outgoing-atom (cog-outgoing-atom e 1) 1))) (cog-incoming-by-type (BondNode "loop-in-file") (quote EdgeLink)))) "|")')
65 const loopFiles = loopFileStr.replace(/^"|"$/g, '').split('|').filter(Boolean)
66
67 console.log('Classifying ' + loopFiles.length + ' files...')
68
69 const batch = []
70 const stats = { lang: {}, domain: {}, role: {} }
71
72 for (const f of loopFiles) {
73 const { lang, domain, role } = classifyFile(f)
74
75 stats.lang[lang] = (stats.lang[lang] || 0) + 1
76 stats.domain[domain] = (stats.domain[domain] || 0) + 1
77 stats.role[role] = (stats.role[role] || 0) + 1
78
79 const node = '(ConceptNode "' + esc(f) + '")'
80 batch.push('(cog-set-value! ' + node + ' (Predicate "language") (StringValue "' + lang + '"))')
81 batch.push('(cog-set-value! ' + node + ' (Predicate "domain") (StringValue "' + domain + '"))')
82 batch.push('(cog-set-value! ' + node + ' (Predicate "role") (StringValue "' + role + '"))')
83
84 if (batch.length >= 90) {
85 await evalScheme('(begin ' + batch.join(' ') + ')')
86 batch.length = 0
87 }
88 }
89
90 if (batch.length > 0) {
91 await evalScheme('(begin ' + batch.join(' ') + ')')
92 }
93
94 console.log('\nClassified ' + loopFiles.length + ' files\n')
95
96 console.log('By language:')
97 for (const [k, v] of Object.entries(stats.lang).sort((a, b) => b[1] - a[1])) {
98 console.log(' ' + String(v).padStart(4) + ' ' + k)
99 }
100
101 console.log('\nBy domain:')
102 for (const [k, v] of Object.entries(stats.domain).sort((a, b) => b[1] - a[1])) {
103 console.log(' ' + String(v).padStart(4) + ' ' + k)
104 }
105
106 console.log('\nBy role:')
107 for (const [k, v] of Object.entries(stats.role).sort((a, b) => b[1] - a[1])) {
108 console.log(' ' + String(v).padStart(4) + ' ' + k)
109 }
110
111 // Now answer the deeper questions
112 console.log('\n=== CROSS-DOMAIN: Correction rate by language ===')
113 // Which languages have the most loops per file?
114 const langLoops = {}
115 const langFiles = {}
116 for (const f of loopFiles) {
117 const { lang } = classifyFile(f)
118 langFiles[lang] = (langFiles[lang] || new Set()).add ? langFiles[lang] || new Set() : new Set()
119 if (!langFiles[lang].add) langFiles[lang] = new Set()
120 langFiles[lang].add(f)
121 }
122
123 // Count actual loops (not unique files) per language
124 const allLoopEdges = await evalScheme(
125 '(string-join (map (lambda (e) (cog-name (cog-outgoing-atom (cog-outgoing-atom e 1) 1))) (cog-incoming-by-type (BondNode "loop-in-file") (quote EdgeLink))) "|")')
126 const loopEdgeFiles = allLoopEdges.replace(/^"|"$/g, '').split('|').filter(Boolean)
127
128 for (const f of loopEdgeFiles) {
129 const { lang } = classifyFile(f)
130 langLoops[lang] = (langLoops[lang] || 0) + 1
131 }
132
133 for (const [lang, loops] of Object.entries(langLoops).sort((a, b) => b[1] - a[1])) {
134 const files = stats.lang[lang] || 1
135 console.log(' ' + lang.padEnd(15) + String(loops).padStart(4) + ' loop-edges across ' + String(files).padStart(3) + ' files (' + (loops / files).toFixed(1) + ' loops/file)')
136 }
137
138 console.log('\n=== CROSS-DOMAIN: Correction rate by domain ===')
139 const domainLoops = {}
140 for (const f of loopEdgeFiles) {
141 const { domain } = classifyFile(f)
142 domainLoops[domain] = (domainLoops[domain] || 0) + 1
143 }
144 for (const [domain, loops] of Object.entries(domainLoops).sort((a, b) => b[1] - a[1])) {
145 const files = stats.domain[domain] || 1
146 console.log(' ' + domain.padEnd(15) + String(loops).padStart(4) + ' loop-edges across ' + String(files).padStart(3) + ' files (' + (loops / files).toFixed(1) + ' loops/file)')
147 }
148}
149
150main().catch(err => { console.error(err.message); process.exit(1) })
atomspace/cogserver.js +129 -0
……@@ -0,0 +1,129 @@
1/**
2 * cogserver.js — CogServer TCP client for Node.js
3 *
4 * Sends Scheme s-expressions to CogServer and reads results.
5 * Protocol: connect to TCP port, send "scm\n" to enter Scheme shell,
6 * send expressions, read until "guile> " prompt.
7 *
8 * All functions gracefully return null/false if CogServer is not running.
9 */
10
11const net = require('net')
12
13const DEFAULT_PORT = 17001
14const DEFAULT_HOST = 'localhost'
15const PROMPT_RE = /guile> / // matches inside ANSI-colored output too
16const OPENCOG_PROMPT_RE = /opencog> /
17const ANSI_RE = /\x1b\[[0-9;]*m/g // strip ANSI color codes
18const CONNECT_TIMEOUT = 2000
19const EVAL_TIMEOUT = 10000
20
21function stripAnsi (str) {
22 return str.replace(ANSI_RE, '')
23}
24
25/**
26 * Check if CogServer is listening on the given port.
27 * Returns true/false, never throws.
28 */
29async function isCogServerUp (port = DEFAULT_PORT, host = DEFAULT_HOST) {
30 return new Promise((resolve) => {
31 const sock = new net.Socket()
32 sock.setTimeout(CONNECT_TIMEOUT)
33 sock.once('connect', () => { sock.destroy(); resolve(true) })
34 sock.once('error', () => { sock.destroy(); resolve(false) })
35 sock.once('timeout', () => { sock.destroy(); resolve(false) })
36 sock.connect(port, host)
37 })
38}
39
40/**
41 * Send a Scheme expression to CogServer and return the result string.
42 * Returns null if CogServer is not reachable.
43 *
44 * @param {string} code - Scheme expression to evaluate
45 * @param {number} port - CogServer port (default 17001)
46 * @param {string} host - CogServer host (default localhost)
47 * @returns {Promise<string|null>} - Result string or null on failure
48 */
49async function evalScheme (code, port = DEFAULT_PORT, host = DEFAULT_HOST) {
50 return new Promise((resolve) => {
51 const sock = new net.Socket()
52 let buffer = ''
53 let phase = 'wait-opencog' // wait-opencog → wait-guile → wait-result
54 let settled = false
55
56 const finish = (result) => {
57 if (settled) return
58 settled = true
59 sock.destroy()
60 resolve(result)
61 }
62
63 sock.setTimeout(EVAL_TIMEOUT)
64 sock.once('error', () => finish(null))
65 sock.once('timeout', () => finish(null))
66
67 sock.on('data', (chunk) => {
68 buffer += chunk.toString()
69 const clean = stripAnsi(buffer)
70
71 if (phase === 'wait-opencog' && OPENCOG_PROMPT_RE.test(clean)) {
72 // Got the initial opencog> prompt — enter Scheme shell
73 phase = 'wait-guile'
74 buffer = ''
75 sock.write('scm\n')
76 return
77 }
78
79 if (phase === 'wait-guile' && PROMPT_RE.test(clean)) {
80 // Got the first guile> prompt — send our code
81 phase = 'wait-result'
82 buffer = ''
83 sock.write(code + '\n')
84 return
85 }
86
87 if (phase === 'wait-result' && PROMPT_RE.test(clean)) {
88 // Got the guile> prompt after our code — extract result
89 const cleanBuf = stripAnsi(buffer)
90 const result = cleanBuf.replace(/guile>\s*/g, '').trim()
91 finish(result)
92 }
93 })
94
95 sock.connect(port, host)
96 })
97}
98
99/**
100 * Send multiple Scheme expressions in one connection.
101 * More efficient than multiple evalScheme calls.
102 * Returns array of result strings (null entries on failure).
103 */
104async function evalSchemeBatch (expressions, port = DEFAULT_PORT, host = DEFAULT_HOST) {
105 if (!expressions.length) return []
106
107 // Wrap all expressions in a begin block
108 const combined = '(begin\n' + expressions.join('\n') + '\n)'
109 const result = await evalScheme(combined, port, host)
110 return result
111}
112
113/**
114 * Send Scheme code without waiting for a meaningful result.
115 * Useful for bulk atom creation where we don't need the return value.
116 */
117async function evalSchemeVoid (code, port = DEFAULT_PORT, host = DEFAULT_HOST) {
118 const result = await evalScheme(code, port, host)
119 return result !== null
120}
121
122module.exports = {
123 isCogServerUp,
124 evalScheme,
125 evalSchemeBatch,
126 evalSchemeVoid,
127 DEFAULT_PORT,
128 DEFAULT_HOST
129}
atomspace/contrast-rules.js +275 -0
……@@ -0,0 +1,275 @@
1#!/usr/bin/env node
2
3/**
4 * contrast-rules.js — Generate meta-cognition atoms from success/failure contrast
5 *
6 * For each resolved loop, contrast filesTried vs resolution.files to find
7 * "approaches that didn't work" and "approach that did work."
8 *
9 * Also clusters loops by (project, language, loop-type) to find patterns that
10 * hold across multiple loops — stronger evidence than single-loop contrasts.
11 *
12 * Output:
13 * meta: atoms with: context, positive-approach, negative-approaches, evidence-count
14 * contrast-edges: meta-atom → file edges (approach-works, approach-fails)
15 *
16 * Usage:
17 * node atomspace/contrast-rules.js # generate and ingest
18 * node atomspace/contrast-rules.js --dry-run # print, don't ingest
19 */
20
21const fs = require('fs')
22const path = require('path')
23const { evalScheme, isCogServerUp } = require('./cogserver')
24const S = require('./schema')
25
26const LOOPS_FILE = path.join(__dirname, '..', 'data', 'loops.json')
27
28function esc (s) {
29 return String(s || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"')
30}
31
32/**
33 * Normalize file path to match codetree conventions.
34 * Strips /storage/projects/code/hypercore-kb/hyper-fleeky/ etc.
35 */
36function normalizePath (p) {
37 if (!p) return ''
38 return p
39 .replace(/^\/storage\/projects\/code\/hypercore-kb\/hyper-fleeky\//, '')
40 .replace(/^\/storage\/projects\/code\/hypercore-kb\//, '')
41 .replace(/^\/storage\/projects\/code\//, '')
42}
43
44/**
45 * Infer language from file extension.
46 */
47function langOf (p) {
48 if (/\.(cpp|cc|h|hpp)$/.test(p)) return 'cpp'
49 if (/\.gd$/.test(p)) return 'gdscript'
50 if (/\.(glsl|gdshader|shader)$/.test(p)) return 'glsl'
51 if (/\.py$/.test(p)) return 'python'
52 if (/\.js$/.test(p)) return 'javascript'
53 if (/\.md$/.test(p)) return 'markdown'
54 if (/\.scm$/.test(p)) return 'scheme'
55 if (/\.json$/.test(p)) return 'json'
56 return 'unknown'
57}
58
59/**
60 * Per-loop contrast: files tried that didn't fix it vs files that did.
61 */
62function contrastPerLoop (loop) {
63 if (!loop.resolved) return null
64 if (!loop.filesTried || !loop.resolution || !loop.resolution.files) return null
65
66 const tried = loop.filesTried.map(normalizePath).filter(Boolean)
67 const worked = loop.resolution.files.map(normalizePath).filter(Boolean)
68 const wasted = tried.filter(f => !worked.includes(f))
69
70 if (worked.length === 0 || wasted.length === 0) return null
71
72 // Primary language of the resolution (usually same-language files cluster together)
73 const workedLangs = worked.map(langOf)
74 const dominantLang = workedLangs[0] || 'unknown'
75
76 return {
77 loopId: loop.fingerprint || loop.id,
78 project: loop.project,
79 type: loop.type,
80 language: dominantLang,
81 worked, // approach that did work
82 wasted, // approaches that didn't work
83 correctionSnippet: (loop.corrections && loop.corrections[0] || '').slice(0, 200),
84 duration: loop.duration,
85 correctionCount: loop.correctionCount || 0
86 }
87}
88
89/**
90 * Cluster per-loop contrasts into cross-loop patterns.
91 * Group by (project, language, loop-type) — loops in same context.
92 *
93 * For each group, compute per-file:
94 * resolveCount — # of loops where this file was in resolution.files
95 * wastedCount — # of loops where this file was in filesTried but NOT resolution
96 *
97 * Files with high resolveCount = "try this first"
98 * Files with high wastedCount = "don't try this"
99 */
100function clusterContrasts (perLoopContrasts) {
101 const groups = new Map()
102 for (const c of perLoopContrasts) {
103 const key = `${c.project}:${c.language}:${c.type}`
104 if (!groups.has(key)) {
105 groups.set(key, { project: c.project, language: c.language, type: c.type, loops: [] })
106 }
107 groups.get(key).loops.push(c)
108 }
109
110 // Build a broader grouping: also group by (project, language) alone — wider nets.
111 const broadGroups = new Map()
112 for (const c of perLoopContrasts) {
113 const key = `${c.project}:${c.language}:*`
114 if (!broadGroups.has(key)) {
115 broadGroups.set(key, { project: c.project, language: c.language, type: '*', loops: [] })
116 }
117 broadGroups.get(key).loops.push(c)
118 }
119 for (const [key, grp] of broadGroups) {
120 if (!groups.has(key)) groups.set(key, grp)
121 }
122
123 const patterns = []
124 for (const [key, grp] of groups) {
125 if (grp.loops.length < 2) continue // need ≥2 loops for a pattern (research threshold)
126
127 const resolveCount = new Map()
128 const wastedCount = new Map()
129 for (const c of grp.loops) {
130 for (const f of c.worked) {
131 resolveCount.set(f, (resolveCount.get(f) || 0) + 1)
132 }
133 for (const f of c.wasted) {
134 wastedCount.set(f, (wastedCount.get(f) || 0) + 1)
135 }
136 }
137
138 // Strong signals: file resolved ≥2 loops, or file was wasted ≥3 times
139 const tryFirst = [...resolveCount.entries()]
140 .filter(([f, n]) => n >= 2)
141 .sort((a, b) => b[1] - a[1])
142 .map(([f, n]) => ({ file: f, evidence: n }))
143
144 const avoid = [...wastedCount.entries()]
145 .filter(([f, n]) => n >= 2 && (resolveCount.get(f) || 0) === 0)
146 .sort((a, b) => b[1] - a[1])
147 .map(([f, n]) => ({ file: f, evidence: n }))
148
149 if (tryFirst.length > 0 || avoid.length > 0) {
150 patterns.push({
151 key,
152 context: { project: grp.project, language: grp.language, type: grp.type },
153 loopCount: grp.loops.length,
154 tryFirst,
155 avoid
156 })
157 }
158 }
159
160 return patterns
161}
162
163/**
164 * Generate Scheme expressions for meta-cognition atoms and ingest them.
165 */
166async function ingestContrasts (perLoopContrasts, patterns, dryRun = false) {
167 const exprs = []
168
169 // Per-loop contrast atoms (individual evidence)
170 for (const c of perLoopContrasts) {
171 const metaId = `meta:loop:${c.loopId}`
172 const metaNode = `(ConceptNode "${esc(metaId)}")`
173
174 exprs.push(metaNode)
175 exprs.push(`(cog-set-value! ${metaNode} (Predicate "kind") (StringValue "per-loop-contrast"))`)
176 exprs.push(`(cog-set-value! ${metaNode} (Predicate "project") (StringValue "${esc(c.project)}"))`)
177 exprs.push(`(cog-set-value! ${metaNode} (Predicate "loop-type") (StringValue "${esc(c.type)}"))`)
178 exprs.push(`(cog-set-value! ${metaNode} (Predicate "language") (StringValue "${esc(c.language)}"))`)
179 exprs.push(`(cog-set-value! ${metaNode} (Predicate "correction-snippet") (StringValue "${esc(c.correctionSnippet)}"))`)
180 exprs.push(`(cog-set-value! ${metaNode} (Predicate "duration") (FloatValue ${c.duration || 0}))`)
181
182 // Link meta → worked files (approach-works)
183 for (const f of c.worked) {
184 exprs.push(`(EdgeLink (BondNode "approach-works") (ListLink ${metaNode} (ConceptNode "file:${esc(f)}")))`)
185 }
186 // Link meta → wasted files (approach-fails)
187 for (const f of c.wasted) {
188 exprs.push(`(EdgeLink (BondNode "approach-fails") (ListLink ${metaNode} (ConceptNode "file:${esc(f)}")))`)
189 }
190 }
191
192 // Cross-loop pattern atoms (aggregate evidence)
193 for (const p of patterns) {
194 const metaId = `meta:pattern:${p.key}`
195 const metaNode = `(ConceptNode "${esc(metaId)}")`
196
197 exprs.push(metaNode)
198 exprs.push(`(cog-set-value! ${metaNode} (Predicate "kind") (StringValue "cross-loop-pattern"))`)
199 exprs.push(`(cog-set-value! ${metaNode} (Predicate "project") (StringValue "${esc(p.context.project)}"))`)
200 exprs.push(`(cog-set-value! ${metaNode} (Predicate "language") (StringValue "${esc(p.context.language)}"))`)
201 exprs.push(`(cog-set-value! ${metaNode} (Predicate "loop-type") (StringValue "${esc(p.context.type)}"))`)
202 exprs.push(`(cog-set-value! ${metaNode} (Predicate "loop-count") (FloatValue ${p.loopCount}))`)
203
204 for (const t of p.tryFirst) {
205 exprs.push(`(EdgeLink (BondNode "try-first") (ListLink ${metaNode} (ConceptNode "file:${esc(t.file)}")))`)
206 // Store evidence count as Value on the EdgeLink? Use a separate counter.
207 }
208 for (const a of p.avoid) {
209 exprs.push(`(EdgeLink (BondNode "avoid-first") (ListLink ${metaNode} (ConceptNode "file:${esc(a.file)}")))`)
210 }
211 }
212
213 console.log(`Generated ${exprs.length} Scheme expressions from:`)
214 console.log(` ${perLoopContrasts.length} per-loop contrasts`)
215 console.log(` ${patterns.length} cross-loop patterns`)
216
217 if (dryRun) {
218 console.log('\n--- SAMPLE PATTERNS (top 5 by loop count) ---')
219 for (const p of patterns.sort((a, b) => b.loopCount - a.loopCount).slice(0, 5)) {
220 console.log(`\n ${p.key} (${p.loopCount} loops)`)
221 if (p.tryFirst.length) {
222 console.log(' try first:')
223 for (const t of p.tryFirst.slice(0, 3)) console.log(` ${t.file} (resolved ${t.evidence} loops)`)
224 }
225 if (p.avoid.length) {
226 console.log(' avoid first:')
227 for (const a of p.avoid.slice(0, 3)) console.log(` ${a.file} (wasted ${a.evidence} times)`)
228 }
229 }
230 return { ok: true, dryRun: true, contrasts: perLoopContrasts.length, patterns: patterns.length }
231 }
232
233 if (!await isCogServerUp()) {
234 console.log('CogServer not running, skipping ingest')
235 return { ok: false, reason: 'cogserver-down' }
236 }
237
238 // Batch ingest
239 const BATCH = 50
240 let sent = 0
241 for (let i = 0; i < exprs.length; i += BATCH) {
242 const batch = exprs.slice(i, i + BATCH)
243 const result = await evalScheme('(begin ' + batch.join(' ') + ')')
244 if (result === null) {
245 console.error(`Connection lost at batch ${i}`)
246 return { ok: false, reason: 'connection-lost', sent }
247 }
248 sent += batch.length
249 }
250
251 console.log(`Ingested ${sent} atoms/edges`)
252 return { ok: true, sent, contrasts: perLoopContrasts.length, patterns: patterns.length }
253}
254
255async function main () {
256 const dryRun = process.argv.includes('--dry-run')
257
258 const loopsData = JSON.parse(fs.readFileSync(LOOPS_FILE, 'utf8'))
259 const loops = loopsData.loops || []
260
261 console.log(`Analyzing ${loops.length} loops...`)
262
263 // Per-loop contrasts
264 const contrasts = loops.map(contrastPerLoop).filter(Boolean)
265 console.log(`Per-loop contrasts: ${contrasts.length}`)
266
267 // Cross-loop patterns
268 const patterns = clusterContrasts(contrasts)
269 console.log(`Cross-loop patterns: ${patterns.length}`)
270
271 const result = await ingestContrasts(contrasts, patterns, dryRun)
272 console.log('\n' + JSON.stringify(result, null, 2))
273}
274
275main().catch(err => { console.error(err.message); process.exit(1) })
atomspace/fire-log.js +106 -0
……@@ -0,0 +1,106 @@
1/**
2 * fire-log.js — Append-only log of rule firings
3 *
4 * Every time a hook injects a rule/meta-atom into context, record it here.
5 * Enables downstream utility tracking and live dashboard display.
6 *
7 * Format: JSONL, one fire per line
8 * { timestamp, session, hook, atom, atomType, wisdom, context, trigger }
9 */
10
11const fs = require('fs')
12const path = require('path')
13
14const LOG_FILE = path.join(__dirname, '..', 'data', 'rule-fires.jsonl')
15const MAX_LINES = 10000 // Rotate when exceeded
16
17function logFire (entry) {
18 try {
19 const record = {
20 timestamp: new Date().toISOString(),
21 session: entry.session || null,
22 hook: entry.hook || 'unknown',
23 atom: entry.atom,
24 atomType: entry.atomType || inferType(entry.atom),
25 wisdom: entry.wisdom != null ? entry.wisdom : null,
26 context: entry.context || null, // file path or prompt excerpt
27 trigger: entry.trigger || null // what caused this fire (file, prompt, etc.)
28 }
29 fs.appendFileSync(LOG_FILE, JSON.stringify(record) + '\n')
30
31 // Phase 4.2: Update last-fired-at and utility-fires counter on atom
32 // Non-blocking, best-effort. Feeds time-decay calculation in wisdom-score.
33 if (entry.atom && entry.session) {
34 setImmediate(() => {
35 try {
36 const { evalScheme, isCogServerUp } = require('./cogserver')
37 isCogServerUp().then(up => {
38 if (!up) return
39 const ts = Math.floor(Date.now() / 1000)
40 const atomName = entry.atom.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
41 evalScheme(
42 `(cog-set-value! (ConceptNode "${atomName}") (Predicate "last-fired-at") (FloatValue ${ts}))`
43 ).catch(() => {})
44 evalScheme(
45 `(cog-set-value! (ConceptNode "${atomName}") (Predicate "utility-fires") ` +
46 `(FloatValue (+ 1 (let ((v (cog-value (ConceptNode "${atomName}") (Predicate "utility-fires")))) ` +
47 `(if v (cog-value-ref v 0) 0)))))`
48 ).catch(() => {})
49 })
50 } catch {}
51 })
52 }
53
54 // Rotate if too large
55 try {
56 const stats = fs.statSync(LOG_FILE)
57 if (stats.size > 5 * 1024 * 1024) { // >5MB
58 const content = fs.readFileSync(LOG_FILE, 'utf8')
59 const lines = content.trim().split('\n')
60 const keep = lines.slice(-MAX_LINES)
61 fs.writeFileSync(LOG_FILE, keep.join('\n') + '\n')
62 }
63 } catch {}
64 } catch {
65 // Silent — never block a hook
66 }
67}
68
69function inferType (atom) {
70 if (!atom) return 'unknown'
71 if (atom.startsWith('rule:')) return 'rule'
72 if (atom.startsWith('meta:pattern:')) return 'pattern'
73 if (atom.startsWith('meta:loop:')) return 'meta-loop'
74 return 'other'
75}
76
77/**
78 * Read recent fires (for dashboard/querying).
79 */
80function readRecent (n = 50) {
81 try {
82 if (!fs.existsSync(LOG_FILE)) return []
83 const content = fs.readFileSync(LOG_FILE, 'utf8')
84 const lines = content.trim().split('\n').filter(Boolean)
85 return lines.slice(-n).map(l => { try { return JSON.parse(l) } catch { return null } }).filter(Boolean)
86 } catch { return [] }
87}
88
89/**
90 * Read fires within a time window (ms).
91 */
92function readSince (sinceMs) {
93 try {
94 if (!fs.existsSync(LOG_FILE)) return []
95 const cutoff = Date.now() - sinceMs
96 const content = fs.readFileSync(LOG_FILE, 'utf8')
97 const lines = content.trim().split('\n').filter(Boolean)
98 const parsed = lines.map(l => { try { return JSON.parse(l) } catch { return null } }).filter(Boolean)
99 return parsed.filter(p => {
100 const t = new Date(p.timestamp).getTime()
101 return t >= cutoff
102 })
103 } catch { return [] }
104}
105
106module.exports = { logFire, readRecent, readSince, LOG_FILE }
atomspace/graph-export.js +278 -0
……@@ -0,0 +1,278 @@
1#!/usr/bin/env node
2
3/**
4 * graph-export.js — Export a slice of AtomSpace as D3-compatible JSON
5 *
6 * Queries AtomSpace for a filtered subgraph and returns {nodes, edges}
7 * in the format expected by D3 force-directed layout.
8 *
9 * Filters (via query params or options):
10 * - module: only include atoms related to this module
11 * - minWisdom: filter atoms below this wisdom score
12 * - nodeTypes: which prefixes to include (file, mod, rule, loop, meta, pkg)
13 * - limit: max nodes to return (default 100)
14 *
15 * Usage:
16 * const { exportGraph } = require('./graph-export')
17 * const graph = await exportGraph({ minWisdom: 0.5, limit: 150 })
18 * // graph = { nodes: [...], edges: [...] }
19 *
20 * CLI:
21 * node graph-export.js # default slice
22 * node graph-export.js --module=pear-git # focused
23 * node graph-export.js --min-wisdom=1.0 # only high-wisdom
24 */
25
26const { evalScheme, isCogServerUp } = require('./cogserver')
27
28// Node type classification by prefix
29const NODE_TYPES = {
30 'file:': 'file',
31 'mod:': 'module',
32 'pkg:': 'package',
33 'fn:': 'function',
34 'rule:': 'rule',
35 'loop:': 'loop',
36 'meta:pattern:': 'pattern',
37 'meta:loop:': 'meta-loop',
38 'session:': 'session',
39 'live:correction:': 'correction',
40 'live:approval:': 'approval',
41 'live:edit:': 'edit'
42}
43
44function classifyAtom (name) {
45 for (const [prefix, type] of Object.entries(NODE_TYPES)) {
46 if (name.startsWith(prefix)) return type
47 }
48 return 'other'
49}
50
51function parseAtomNames (resultStr) {
52 if (!resultStr) return []
53 const names = []
54 const re = /(?:ConceptNode|Concept) "([^"]+)"/g
55 let m
56 while ((m = re.exec(resultStr)) !== null) names.push(m[1])
57 return names
58}
59
60/**
61 * Get wisdom score for an atom (0 if not scored).
62 */
63async function getWisdom (name) {
64 const result = await evalScheme(
65 `(let ((v (cog-value (ConceptNode "${name.replace(/"/g, '\\"')}") (Predicate "wisdom")))) (if v (cog-value-ref v 0) 0))`)
66 return parseFloat(result) || 0
67}
68
69/**
70 * Collect candidate atoms based on filter criteria.
71 */
72async function collectCandidates (opts) {
73 await evalScheme('(use-modules (srfi srfi-1))')
74
75 // If a specific module requested, start from there and expand
76 if (opts.module) {
77 return await collectModuleSlice(opts.module, opts.limit || 100)
78 }
79
80 // Otherwise: top wisdom atoms + their connections
81 return await collectTopWisdomSlice(opts.minWisdom || 0.5, opts.limit || 100)
82}
83
84/**
85 * Collect a slice centered on one module.
86 */
87async function collectModuleSlice (moduleName, limit) {
88 const modNode = `(ConceptNode "mod:${moduleName.replace(/"/g, '\\"')}")`
89 const atoms = new Set([`mod:${moduleName}`])
90
91 // Files contained in this module
92 const filesStr = await evalScheme(
93 `(cog-execute! (Meet (TypedVariable (Variable "$f") (Type (quote ConceptNode))) (Present (EdgeLink (BondNode "contains") (ListLink ${modNode} (Variable "$f"))))))`)
94 for (const n of parseAtomNames(filesStr)) atoms.add(n)
95
96 // Files this module depends on / imports
97 const depsStr = await evalScheme(
98 `(cog-execute! (Meet (TypedVariable (Variable "$d") (Type (quote ConceptNode))) (Present (EdgeLink (BondNode "depends-on") (ListLink ${modNode} (Variable "$d"))))))`)
99 for (const n of parseAtomNames(depsStr)) atoms.add(n)
100
101 // Rules and meta atoms for the files in this module
102 const files = [...atoms].filter(n => n.startsWith('file:'))
103 for (const file of files.slice(0, 30)) {
104 const fileAtom = `(ConceptNode "${file.replace(/"/g, '\\"')}")`
105 const rulesStr = await evalScheme(
106 `(cog-execute! (Meet (TypedVariable (Variable "$r") (Type (quote ConceptNode))) (Present (EdgeLink (BondNode "rule-for-file") (ListLink (Variable "$r") ${fileAtom})))))`)
107 for (const n of parseAtomNames(rulesStr)) atoms.add(n)
108
109 const loopsStr = await evalScheme(
110 `(cog-execute! (Meet (TypedVariable (Variable "$l") (Type (quote ConceptNode))) (Present (EdgeLink (BondNode "loop-in-file") (ListLink (Variable "$l") ${fileAtom})))))`)
111 for (const n of parseAtomNames(loopsStr)) atoms.add(n)
112
113 if (atoms.size >= limit) break
114 }
115
116 return [...atoms].slice(0, limit)
117}
118
119/**
120 * Collect top-wisdom atoms plus their immediate neighbors.
121 */
122async function collectTopWisdomSlice (minWisdom, limit) {
123 // Get all meta atoms and rules with wisdom values
124 const allStr = await evalScheme(
125 '(string-join (map cog-name (cog-get-atoms (quote ConceptNode))) "|")')
126 const allAtoms = (allStr || '').replace(/^"|"$/g, '').split('|').filter(Boolean)
127
128 const metaAndRules = allAtoms.filter(n =>
129 n.startsWith('rule:') || n.startsWith('meta:pattern:') || n.startsWith('meta:loop:'))
130
131 const scored = []
132 for (const name of metaAndRules) {
133 const wisdom = await getWisdom(name)
134 if (wisdom >= minWisdom) scored.push({ name, wisdom })
135 }
136
137 scored.sort((a, b) => b.wisdom - a.wisdom)
138 const atoms = new Set(scored.slice(0, Math.min(50, Math.floor(limit / 2))).map(s => s.name))
139
140 // Expand to include connected files and modules
141 for (const atomName of [...atoms]) {
142 if (atoms.size >= limit) break
143
144 const atomLiteral = `(ConceptNode "${atomName.replace(/"/g, '\\"')}")`
145 const bonds = atomName.startsWith('rule:')
146 ? ['rule-for-file']
147 : atomName.startsWith('meta:pattern:')
148 ? ['try-first', 'avoid-first']
149 : ['approach-works', 'approach-fails']
150
151 for (const bond of bonds) {
152 if (atoms.size >= limit) break
153 const result = await evalScheme(
154 `(cog-execute! (Meet (TypedVariable (Variable "$f") (Type (quote ConceptNode))) (Present (EdgeLink (BondNode "${bond}") (ListLink ${atomLiteral} (Variable "$f"))))))`)
155 for (const n of parseAtomNames(result)) {
156 if (atoms.size >= limit) break
157 atoms.add(n)
158 }
159 }
160 }
161
162 // Also add the modules that contain the files
163 const files = [...atoms].filter(n => n.startsWith('file:'))
164 for (const file of files.slice(0, 30)) {
165 if (atoms.size >= limit) break
166 const fileAtom = `(ConceptNode "${file.replace(/"/g, '\\"')}")`
167 const modStr = await evalScheme(
168 `(cog-execute! (Meet (TypedVariable (Variable "$m") (Type (quote ConceptNode))) (Present (EdgeLink (BondNode "contains") (ListLink (Variable "$m") ${fileAtom})))))`)
169 for (const n of parseAtomNames(modStr)) {
170 if (atoms.size >= limit) break
171 atoms.add(n)
172 }
173 }
174
175 return [...atoms]
176}
177
178/**
179 * Collect all edges between the given set of atoms.
180 */
181async function collectEdges (atomSet) {
182 const atomNames = new Set(atomSet)
183 const edges = []
184 const bondTypes = [
185 'contains', 'imports', 'depends-on', 'exports',
186 'loop-in-file', 'rule-for-file', 'resolved-by',
187 'approach-works', 'approach-fails', 'try-first', 'avoid-first'
188 ]
189
190 for (const bond of bondTypes) {
191 const edgeStr = await evalScheme(
192 `(cog-incoming-by-type (BondNode "${bond}") (quote EdgeLink))`)
193 if (!edgeStr) continue
194
195 // Parse: each Edge has (Bond X) (List A B)
196 const listPattern = /\(List\s+\(Concept "([^"]+)"\)\s+\(Concept "([^"]+)"\)\)/g
197 let m
198 while ((m = listPattern.exec(edgeStr)) !== null) {
199 const [, src, dst] = m
200 if (atomNames.has(src) && atomNames.has(dst)) {
201 edges.push({ source: src, target: dst, type: bond })
202 }
203 }
204 }
205
206 return edges
207}
208
209/**
210 * Build a D3-ready graph.
211 */
212async function exportGraph (opts = {}) {
213 if (!await isCogServerUp()) {
214 return { nodes: [], edges: [], error: 'CogServer not running' }
215 }
216
217 const candidates = await collectCandidates(opts)
218 if (candidates.length === 0) {
219 return { nodes: [], edges: [], error: 'no atoms matched filters' }
220 }
221
222 // Fetch wisdom for all candidates (for sizing)
223 const nodes = []
224 for (const name of candidates) {
225 const type = classifyAtom(name)
226 let wisdom = 0
227 if (name.startsWith('rule:') || name.startsWith('meta:')) {
228 wisdom = await getWisdom(name)
229 }
230
231 // Short display label
232 let label = name
233 if (name.startsWith('file:')) label = name.slice(5).split('/').pop()
234 else if (name.startsWith('mod:')) label = name.slice(4)
235 else if (name.startsWith('pkg:')) label = name.slice(4)
236 else if (name.startsWith('rule:')) label = name.slice(5, 25) + '…'
237 else if (name.startsWith('meta:pattern:')) label = name.slice(13)
238 else if (name.startsWith('meta:loop:')) label = 'loop:' + name.slice(10, 18)
239 else if (name.startsWith('loop:')) label = name.slice(5, 25) + '…'
240
241 nodes.push({
242 id: name,
243 type,
244 label,
245 wisdom,
246 // Fully-qualified for tooltip
247 fullName: name
248 })
249 }
250
251 const edges = await collectEdges(candidates)
252
253 return {
254 nodes,
255 edges,
256 stats: {
257 nodeCount: nodes.length,
258 edgeCount: edges.length,
259 byType: nodes.reduce((acc, n) => { acc[n.type] = (acc[n.type] || 0) + 1; return acc }, {})
260 }
261 }
262}
263
264// CLI
265if (require.main === module) {
266 const args = process.argv.slice(2)
267 const opts = {}
268 for (const a of args) {
269 if (a.startsWith('--module=')) opts.module = a.split('=')[1]
270 else if (a.startsWith('--min-wisdom=')) opts.minWisdom = parseFloat(a.split('=')[1])
271 else if (a.startsWith('--limit=')) opts.limit = parseInt(a.split('=')[1])
272 }
273 exportGraph(opts).then(result => {
274 console.log(JSON.stringify(result, null, 2))
275 }).catch(err => { console.error(err.message); process.exit(1) })
276}
277
278module.exports = { exportGraph }
atomspace/graph.html +384 -0
……@@ -0,0 +1,384 @@
1<!DOCTYPE html>
2<html lang="en">
3<head>
4<meta charset="utf-8">
5<title>AtomSpace Knowledge Graph</title>
6<script src="https://d3js.org/d3.v7.min.js"></script>
7<style>
8 body {
9 font-family: -apple-system, 'Segoe UI', sans-serif;
10 margin: 0;
11 background: #0d1117;
12 color: #c9d1d9;
13 overflow: hidden;
14 }
15 #app { display: flex; height: 100vh; }
16 #sidebar {
17 width: 320px;
18 padding: 16px;
19 background: #161b22;
20 border-right: 1px solid #30363d;
21 overflow-y: auto;
22 }
23 #graph-container { flex: 1; position: relative; }
24 svg { width: 100%; height: 100%; cursor: grab; }
25 svg:active { cursor: grabbing; }
26 h1 { font-size: 18px; margin: 0 0 12px; color: #58a6ff; }
27 h2 { font-size: 13px; margin: 16px 0 6px; color: #8b949e; text-transform: uppercase; letter-spacing: 0.08em; }
28 label { display: block; font-size: 12px; margin: 8px 0 4px; color: #8b949e; }
29 input[type="text"], input[type="number"], select {
30 width: 100%;
31 padding: 6px 8px;
32 background: #0d1117;
33 border: 1px solid #30363d;
34 color: #c9d1d9;
35 border-radius: 4px;
36 font-size: 13px;
37 box-sizing: border-box;
38 }
39 button {
40 padding: 6px 12px;
41 background: #238636;
42 border: 1px solid #2ea043;
43 color: white;
44 border-radius: 4px;
45 cursor: pointer;
46 font-size: 13px;
47 margin-top: 8px;
48 }
49 button:hover { background: #2ea043; }
50 .stat { display: flex; justify-content: space-between; padding: 4px 0; font-size: 13px; }
51 .stat-val { color: #58a6ff; font-variant-numeric: tabular-nums; }
52 .legend-item { display: flex; align-items: center; margin: 4px 0; font-size: 12px; }
53 .legend-dot { width: 12px; height: 12px; border-radius: 50%; margin-right: 8px; }
54 #tooltip {
55 position: absolute;
56 background: #161b22;
57 border: 1px solid #30363d;
58 padding: 8px 12px;
59 border-radius: 4px;
60 font-size: 12px;
61 pointer-events: none;
62 max-width: 360px;
63 z-index: 100;
64 display: none;
65 }
66 #tooltip strong { color: #58a6ff; }
67 .node { cursor: pointer; }
68 .node:hover { stroke: white; stroke-width: 2; }
69 .node.selected { stroke: #f85149; stroke-width: 3; }
70 .node.firing {
71 stroke: #3fb950;
72 stroke-width: 3;
73 animation: pulse 2s infinite;
74 }
75 @keyframes pulse {
76 0% { stroke-opacity: 1; }
77 50% { stroke-opacity: 0.4; }
78 100% { stroke-opacity: 1; }
79 }
80 .link { stroke-opacity: 0.4; }
81 .label { font-size: 10px; fill: #8b949e; pointer-events: none; text-anchor: middle; }
82 #details { margin-top: 16px; padding: 12px; background: #0d1117; border-radius: 4px; font-size: 12px; line-height: 1.5; display: none; }
83 #details strong { color: #58a6ff; }
84 #details pre { margin: 4px 0; font-size: 11px; overflow-x: auto; color: #8b949e; }
85 .small { font-size: 11px; color: #6e7681; }
86</style>
87</head>
88<body>
89<div id="app">
90 <div id="sidebar">
91 <h1>AtomSpace Graph</h1>
92 <p class="small">Knowledge-ratchet meta-cognition layer. Node size = wisdom score. Drag nodes to explore.</p>
93
94 <h2>Filters</h2>
95 <label>Module (leave empty for top-wisdom slice)</label>
96 <input type="text" id="module-input" placeholder="e.g. pear-git">
97 <label>Minimum wisdom</label>
98 <input type="number" id="wisdom-input" value="0.5" step="0.1" min="0">
99 <label>Node limit</label>
100 <input type="number" id="limit-input" value="100" step="10" min="20" max="500">
101 <label><input type="checkbox" id="hide-orphans" checked> Hide orphans (no edges)</label>
102 <button onclick="loadGraph()">Reload</button>
103
104 <h2>Stats</h2>
105 <div id="stats"></div>
106
107 <h2>Live Rule Firings
108 <span id="fires-status" class="small" style="float: right; color: #6e7681;">●</span>
109 </h2>
110 <div id="fires" class="small" style="max-height: 220px; overflow-y: auto; margin-bottom: 12px;"></div>
111
112 <h2>Legend</h2>
113 <div id="legend"></div>
114
115 <div id="details"></div>
116 </div>
117 <div id="graph-container">
118 <svg id="graph"></svg>
119 <div id="tooltip"></div>
120 </div>
121</div>
122
123<script>
124const COLORS = {
125 'file': '#58a6ff',
126 'module': '#3fb950',
127 'package': '#6e7681',
128 'function': '#39c5cf',
129 'rule': '#f0883e',
130 'loop': '#f85149',
131 'pattern': '#bc8cff',
132 'meta-loop': '#d2a8ff',
133 'session': '#e3b341',
134 'correction': '#ff7b72',
135 'approval': '#7ee787',
136 'edit': '#a5a5a5',
137 'other': '#8b949e'
138}
139
140const EDGE_COLORS = {
141 'contains': '#30363d',
142 'imports': '#58a6ff33',
143 'depends-on': '#58a6ff66',
144 'exports': '#3fb95066',
145 'loop-in-file': '#f8514966',
146 'rule-for-file': '#f0883e66',
147 'resolved-by': '#7ee78766',
148 'approach-works': '#7ee78799',
149 'approach-fails': '#ff7b7299',
150 'try-first': '#bc8cff99',
151 'avoid-first': '#ff7b7299'
152}
153
154const svg = d3.select('#graph')
155const tooltip = document.getElementById('tooltip')
156let simulation = null
157
158function renderLegend () {
159 const el = document.getElementById('legend')
160 el.innerHTML = ''
161 for (const [type, color] of Object.entries(COLORS)) {
162 const div = document.createElement('div')
163 div.className = 'legend-item'
164 div.innerHTML = '<div class="legend-dot" style="background:' + color + '"></div>' + type
165 el.appendChild(div)
166 }
167}
168
169function renderStats (stats) {
170 const el = document.getElementById('stats')
171 if (!stats) { el.innerHTML = '<div class="stat">No data</div>'; return }
172 let html = ''
173 html += '<div class="stat">nodes<span class="stat-val">' + stats.nodeCount + '</span></div>'
174 html += '<div class="stat">edges<span class="stat-val">' + stats.edgeCount + '</span></div>'
175 if (stats.byType) {
176 for (const [type, count] of Object.entries(stats.byType).sort((a,b) => b[1]-a[1])) {
177 html += '<div class="stat">' + type + '<span class="stat-val">' + count + '</span></div>'
178 }
179 }
180 el.innerHTML = html
181}
182
183function nodeRadius (d) {
184 // Base radius by type
185 const base = {
186 'file': 5, 'module': 10, 'package': 5, 'function': 4,
187 'rule': 7, 'loop': 6, 'pattern': 12, 'meta-loop': 7,
188 'session': 9, 'correction': 6, 'approval': 6, 'edit': 4, 'other': 5
189 }[d.type] || 5
190 // Wisdom bonus (only for rule/pattern)
191 if (d.wisdom && d.wisdom > 0) {
192 return base + Math.min(10, d.wisdom * 4)
193 }
194 return base
195}
196
197function renderGraph (data) {
198 svg.selectAll('*').remove()
199 const width = svg.node().clientWidth
200 const height = svg.node().clientHeight
201
202 const g = svg.append('g')
203
204 // Zoom + pan
205 const zoom = d3.zoom()
206 .scaleExtent([0.1, 8])
207 .on('zoom', (e) => g.attr('transform', e.transform))
208 svg.call(zoom)
209
210 if (simulation) simulation.stop()
211
212 // Optionally filter out orphans (no connecting edges)
213 const hideOrphans = document.getElementById('hide-orphans').checked
214 let nodes = data.nodes.map(n => ({ ...n }))
215 let links = data.edges.map(e => ({ source: e.source, target: e.target, type: e.type }))
216
217 if (hideOrphans) {
218 const connected = new Set()
219 for (const e of links) { connected.add(e.source); connected.add(e.target) }
220 nodes = nodes.filter(n => connected.has(n.id))
221 }
222
223 // Tighter layout: smaller charge, stronger center, bounded radial
224 simulation = d3.forceSimulation(nodes)
225 .force('link', d3.forceLink(links).id(d => d.id).distance(d => d.type === 'contains' ? 30 : 60).strength(0.6))
226 .force('charge', d3.forceManyBody().strength(-120).distanceMax(300))
227 .force('center', d3.forceCenter(width/2, height/2))
228 .force('collide', d3.forceCollide().radius(d => nodeRadius(d) + 3).strength(0.9))
229 .force('x', d3.forceX(width/2).strength(0.05))
230 .force('y', d3.forceY(height/2).strength(0.05))
231 .alphaDecay(0.025)
232
233 const link = g.append('g').selectAll('line').data(links).join('line')
234 .attr('class', 'link')
235 .attr('stroke', d => EDGE_COLORS[d.type] || '#30363d')
236 .attr('stroke-width', 1)
237
238 const node = g.append('g').selectAll('circle').data(nodes).join('circle')
239 .attr('class', 'node')
240 .attr('r', nodeRadius)
241 .attr('fill', d => COLORS[d.type] || COLORS.other)
242 .attr('stroke', '#161b22')
243 .attr('stroke-width', 1.5)
244 .call(d3.drag()
245 .on('start', (e, d) => { if (!e.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y })
246 .on('drag', (e, d) => { d.fx = e.x; d.fy = e.y })
247 .on('end', (e, d) => { if (!e.active) simulation.alphaTarget(0); d.fx = null; d.fy = null }))
248 .on('mouseover', (e, d) => {
249 tooltip.style.display = 'block'
250 tooltip.style.left = (e.pageX + 10) + 'px'
251 tooltip.style.top = (e.pageY + 10) + 'px'
252 tooltip.innerHTML = '<strong>' + d.type + '</strong><br>' + d.fullName +
253 (d.wisdom > 0 ? '<br>wisdom: ' + d.wisdom.toFixed(2) : '')
254 })
255 .on('mouseout', () => { tooltip.style.display = 'none' })
256 .on('click', (e, d) => showDetails(d))
257
258 const label = g.append('g').selectAll('text').data(nodes).join('text')
259 .attr('class', 'label')
260 .text(d => d.label)
261 .attr('dy', d => nodeRadius(d) + 10)
262
263 simulation.on('tick', () => {
264 link
265 .attr('x1', d => d.source.x).attr('y1', d => d.source.y)
266 .attr('x2', d => d.target.x).attr('y2', d => d.target.y)
267 node.attr('cx', d => d.x).attr('cy', d => d.y)
268 label.attr('x', d => d.x).attr('y', d => d.y)
269 })
270}
271
272function showDetails (d) {
273 const el = document.getElementById('details')
274 el.style.display = 'block'
275 el.innerHTML = '<strong>' + d.fullName + '</strong>' +
276 '<br>type: ' + d.type +
277 (d.wisdom > 0 ? '<br>wisdom: ' + d.wisdom.toFixed(2) : '')
278}
279
280async function loadGraph () {
281 const module = document.getElementById('module-input').value.trim()
282 const wisdom = document.getElementById('wisdom-input').value
283 const limit = document.getElementById('limit-input').value
284 const params = new URLSearchParams()
285 if (module) params.set('module', module)
286 if (wisdom) params.set('minWisdom', wisdom)
287 if (limit) params.set('limit', limit)
288
289 try {
290 const res = await fetch('graph.json?' + params.toString())
291 const data = await res.json()
292 renderStats(data.stats)
293 renderGraph(data)
294 } catch (err) {
295 alert('Load failed: ' + err.message)
296 }
297}
298
299renderLegend()
300loadGraph()
301
302// ── Live rule firings — polling every 3 seconds ──
303let firingAtoms = new Set() // atoms that fired recently
304let lastSeenTimestamp = 0
305
306const PULSE_WINDOW_MS = 300000 // 5 minutes — atoms fired within this window pulse
307let lastFireTimestamp = null
308
309async function pollFires () {
310 try {
311 const res = await fetch('fires.json?limit=50')
312 const data = await res.json()
313 const fires = data.fires || []
314
315 // Detect new fires since last poll
316 const newestTs = fires.length > 0 ? new Date(fires[fires.length-1].timestamp).getTime() : 0
317 const isNew = lastFireTimestamp !== null && newestTs > lastFireTimestamp
318 lastFireTimestamp = newestTs
319
320 // Update sidebar list
321 const el = document.getElementById('fires')
322 if (fires.length === 0) {
323 el.innerHTML = '<div style="color:#6e7681; padding: 8px 0">No fires logged yet.<br><span style="font-size: 10px">Rules fire when: (1) you mention a file in a prompt, or (2) Claude edits a shared module</span></div>'
324 } else {
325 el.innerHTML = fires.slice(-15).reverse().map((f, idx) => {
326 const age = Date.now() - new Date(f.timestamp).getTime()
327 const ageStr = age < 60000 ? Math.round(age/1000) + 's'
328 : age < 3600000 ? Math.round(age/60000) + 'm'
329 : Math.round(age/3600000) + 'h'
330 const hookColor = f.hook === 'preflight' ? '#f0883e' : '#58a6ff'
331 const label = f.atom.length > 50 ? f.atom.slice(0, 50) + '…' : f.atom
332 const trigger = f.trigger ? ' <span style="color:#6e7681">[' + String(f.trigger).slice(0, 40) + ']</span>' : ''
333 const isRecent = age < PULSE_WINDOW_MS
334 const bg = isRecent ? 'background: rgba(63, 185, 80, 0.08);' : ''
335 return `<div style="padding: 3px 6px; border-bottom: 1px solid #21262d; ${bg}">
336 <span style="color:${hookColor}; font-size: 10px">${f.hook}</span>
337 <span style="color:#6e7681; font-size: 10px; float: right">${ageStr} ago</span>
338 <br><span style="color:#c9d1d9">${label}</span>${trigger}
339 </div>`
340 }).join('')
341 }
342
343 // Update firing atom set (pulse green) — atoms fired in last 5 minutes
344 const recent = fires.filter(f => (Date.now() - new Date(f.timestamp).getTime()) < PULSE_WINDOW_MS)
345 const newFiring = new Set(recent.map(f => f.atom))
346 const changed = newFiring.size !== firingAtoms.size ||
347 [...newFiring].some(a => !firingAtoms.has(a))
348 if (changed) {
349 firingAtoms = newFiring
350 d3.selectAll('.node')
351 .classed('firing', d => firingAtoms.has(d.id))
352 }
353
354 // Status indicator
355 const status = document.getElementById('fires-status')
356 const recentCount = recent.length
357 if (recentCount > 0) {
358 status.style.color = '#3fb950'
359 status.textContent = '● ' + recentCount + ' active (' + fires.length + ' total)'
360 } else if (fires.length > 0) {
361 status.style.color = '#e3b341'
362 status.textContent = '○ ' + fires.length + ' total, none recent'
363 } else {
364 status.style.color = '#6e7681'
365 status.textContent = '○ idle'
366 }
367
368 // Flash the status on new fire
369 if (isNew) {
370 status.style.color = '#58a6ff'
371 status.textContent = '▸ NEW FIRE'
372 setTimeout(() => pollFires(), 300)
373 }
374 } catch (err) {
375 document.getElementById('fires-status').style.color = '#f85149'
376 document.getElementById('fires-status').textContent = '✗ poll error'
377 }
378}
379
380setInterval(pollFires, 3000)
381pollFires()
382</script>
383</body>
384</html>
atomspace/hook-utility.js +199 -0
……@@ -0,0 +1,199 @@
1#!/usr/bin/env node
2
3/**
4 * hook-utility.js — Falsify hook utility by joining fires with corrections
5 *
6 * Analysis:
7 * For each rule-fire recorded in rule-fires.jsonl:
8 * Find corrections in the same session that came AFTER the fire.
9 * If a correction occurred within N turns → rule fired but did NOT prevent it.
10 * If no correction within N turns → rule may have prevented one.
11 *
12 * Per hook (attention-router, preflight):
13 * total_fires — how often the hook fired
14 * follow_up_correction — fires followed by correction within window
15 * no_correction — fires NOT followed by correction (plausibly helpful)
16 *
17 * Per rule/atom:
18 * same analysis at atom level — which specific rules are useful
19 *
20 * NOTE: This is OBSERVATIONAL, not causal. We can't prove a rule PREVENTED
21 * a correction — only that fires correlate with outcomes.
22 *
23 * Usage:
24 * node atomspace/hook-utility.js # report
25 * node atomspace/hook-utility.js --window=5 # corrections within N turns = "failed"
26 * node atomspace/hook-utility.js --json # machine-readable output
27 */
28
29const fs = require('fs')
30const path = require('path')
31
32const FIRES_FILE = path.join(__dirname, '..', 'data', 'rule-fires.jsonl')
33const CORRECTIONS_LOG = path.join(__dirname, '..', 'corrections.log')
34const SESSION_STATE_FILE = '/tmp/claude-session-score.json'
35
36const DEFAULT_WINDOW_SECONDS = 600 // 10 minutes default
37
38function readFires () {
39 if (!fs.existsSync(FIRES_FILE)) return []
40 const content = fs.readFileSync(FIRES_FILE, 'utf8')
41 return content.trim().split('\n').filter(Boolean).map(l => {
42 try { return JSON.parse(l) } catch { return null }
43 }).filter(Boolean)
44}
45
46/**
47 * Parse corrections.log into array of { timestamp, type, message, cwd }.
48 * Format:
49 * ---
50 * 2026-04-13T20:29:28.857Z [correction]
51 * message text...
52 * cwd: /path
53 */
54function readCorrections () {
55 if (!fs.existsSync(CORRECTIONS_LOG)) return []
56 const content = fs.readFileSync(CORRECTIONS_LOG, 'utf8')
57 const blocks = content.split('\n---\n').filter(Boolean)
58 const entries = []
59 for (const block of blocks) {
60 const lines = block.trim().split('\n')
61 if (lines.length < 1) continue
62 const m = lines[0].match(/^(\d{4}-\d{2}-\d{2}T[\d:.]+Z)\s*\[(correction|approval)\]/)
63 if (!m) continue
64 const timestamp = m[1]
65 const type = m[2]
66 const cwdLine = lines.find(l => l.startsWith('cwd:'))
67 const cwd = cwdLine ? cwdLine.slice(4).trim() : null
68 const message = lines.slice(1).filter(l => !l.startsWith('cwd:')).join('\n').trim()
69 entries.push({ timestamp, type, message, cwd, ts: new Date(timestamp).getTime() })
70 }
71 return entries
72}
73
74/**
75 * For each fire, check if a correction followed in the same cwd within window.
76 */
77function analyzeFires (fires, corrections, windowSec) {
78 const windowMs = windowSec * 1000
79
80 // Only analyze corrections (not approvals)
81 const correctionEvents = corrections.filter(c => c.type === 'correction')
82
83 const results = []
84 for (const fire of fires) {
85 const fireTs = new Date(fire.timestamp).getTime()
86 // Find corrections in the window that came AFTER this fire
87 const followUp = correctionEvents.filter(c =>
88 c.ts > fireTs && c.ts <= fireTs + windowMs
89 )
90 results.push({ ...fire, fireTs, followUpCount: followUp.length, hadCorrection: followUp.length > 0 })
91 }
92 return results
93}
94
95/**
96 * Aggregate utility by hook and by atom.
97 */
98function aggregate (analyzed) {
99 const byHook = {}
100 const byAtom = {}
101
102 for (const r of analyzed) {
103 // Hook-level
104 if (!byHook[r.hook]) byHook[r.hook] = { fires: 0, followedByCorrection: 0, cleanFires: 0 }
105 byHook[r.hook].fires++
106 if (r.hadCorrection) byHook[r.hook].followedByCorrection++
107 else byHook[r.hook].cleanFires++
108
109 // Atom-level
110 const key = r.atom
111 if (!byAtom[key]) byAtom[key] = { fires: 0, followedByCorrection: 0, cleanFires: 0 }
112 byAtom[key].fires++
113 if (r.hadCorrection) byAtom[key].followedByCorrection++
114 else byAtom[key].cleanFires++
115 }
116
117 // Compute "clean rate" — fires not followed by correction
118 for (const stats of Object.values(byHook)) {
119 stats.cleanRate = stats.fires > 0 ? stats.cleanFires / stats.fires : 0
120 }
121 for (const stats of Object.values(byAtom)) {
122 stats.cleanRate = stats.fires > 0 ? stats.cleanFires / stats.fires : 0
123 }
124
125 return { byHook, byAtom }
126}
127
128function main () {
129 const args = process.argv.slice(2)
130 const windowArg = args.find(a => a.startsWith('--window='))
131 const windowSec = windowArg ? parseInt(windowArg.split('=')[1]) : DEFAULT_WINDOW_SECONDS
132 const jsonOutput = args.includes('--json')
133
134 const fires = readFires()
135 const corrections = readCorrections()
136
137 if (fires.length === 0) {
138 console.log('No fires logged yet. Hooks need to fire first.')
139 process.exit(0)
140 }
141
142 const analyzed = analyzeFires(fires, corrections, windowSec)
143 const { byHook, byAtom } = aggregate(analyzed)
144
145 if (jsonOutput) {
146 console.log(JSON.stringify({ byHook, byAtom, windowSec, totalFires: fires.length, totalCorrections: corrections.length }, null, 2))
147 return
148 }
149
150 console.log('=== Hook Utility Analysis ===')
151 console.log(`Window: ${windowSec}s (correction within this time after fire = "followed by correction")`)
152 console.log(`Total fires logged: ${fires.length}`)
153 console.log(`Total corrections logged: ${corrections.length}`)
154 console.log('')
155
156 console.log('── Per Hook ──')
157 for (const [hook, stats] of Object.entries(byHook).sort((a, b) => b[1].fires - a[1].fires)) {
158 const pct = (stats.cleanRate * 100).toFixed(1)
159 console.log(` ${hook.padEnd(22)} fires=${String(stats.fires).padStart(4)} followed-by-correction=${String(stats.followedByCorrection).padStart(3)} clean=${String(stats.cleanFires).padStart(3)} clean-rate=${pct}%`)
160 }
161
162 console.log('')
163 console.log('── Top 10 "good" atoms (high clean rate, ≥3 fires) ──')
164 const good = Object.entries(byAtom)
165 .filter(([a, s]) => s.fires >= 3)
166 .sort((a, b) => b[1].cleanRate - a[1].cleanRate)
167 .slice(0, 10)
168 for (const [atom, stats] of good) {
169 const pct = (stats.cleanRate * 100).toFixed(0)
170 const label = atom.length > 60 ? atom.slice(0, 60) + '…' : atom
171 console.log(` clean=${pct}% (${stats.cleanFires}/${stats.fires}) ${label}`)
172 }
173
174 console.log('')
175 console.log('── Bottom 10 "wasteful" atoms (low clean rate, ≥3 fires) ──')
176 const bad = Object.entries(byAtom)
177 .filter(([a, s]) => s.fires >= 3)
178 .sort((a, b) => a[1].cleanRate - b[1].cleanRate)
179 .slice(0, 10)
180 for (const [atom, stats] of bad) {
181 const pct = (stats.cleanRate * 100).toFixed(0)
182 const label = atom.length > 60 ? atom.slice(0, 60) + '…' : atom
183 console.log(` clean=${pct}% (${stats.cleanFires}/${stats.fires}) ${label}`)
184 }
185
186 console.log('')
187 console.log('── Caveat ──')
188 console.log(' This is OBSERVATIONAL — we only see correlation between fires and subsequent')
189 console.log(' corrections. We cannot prove the fires PREVENTED corrections, only that atoms')
190 console.log(' with high clean-rate are associated with better outcomes.')
191 console.log(' Low clean-rate atoms may be wasteful fires OR may be firing on genuinely')
192 console.log(' risky contexts where corrections happen regardless.')
193}
194
195if (require.main === module) {
196 main()
197}
198
199module.exports = { readFires, readCorrections, analyzeFires, aggregate }
atomspace/ingest-codetree.js +221 -0
……@@ -0,0 +1,221 @@
1/**
2 * ingest-codetree.js — Load codetree scan output into AtomSpace
3 *
4 * Runs codetree scan (or reads cached JSON), converts to Atomese atoms:
5 * - Module nodes (mod:NAME)
6 * - File nodes (file:MODULE/PATH)
7 * - Function/class nodes (fn:NAME)
8 * - EdgeLinks: contains, imports, depends-on, exports
9 *
10 * Usage:
11 * node ingest-codetree.js [scan-dir]
12 * Default scan-dir: ../../hyper-fleeky
13 */
14
15const { execSync } = require('child_process')
16const path = require('path')
17const fs = require('fs')
18const { evalScheme, evalSchemeVoid, isCogServerUp } = require('./cogserver')
19const S = require('./schema')
20
21const CODETREE_BIN = path.resolve(__dirname, '../../hyper-fleeky/codetree/index.js')
22const CACHE_FILE = path.resolve(__dirname, '../data/codetree-cache.json')
23const HASH_FILE = path.resolve(__dirname, '../data/codetree-hash.txt')
24
25/**
26 * Run codetree scan and return parsed JSON.
27 * Uses cache if available and unchanged.
28 */
29function scanCodetree (scanDir) {
30 const dir = scanDir || path.resolve(__dirname, '../../hyper-fleeky')
31
32 try {
33 const output = execSync(`node ${CODETREE_BIN} scan ${dir}`, {
34 encoding: 'utf8',
35 timeout: 60000,
36 maxBuffer: 10 * 1024 * 1024
37 })
38 const tree = JSON.parse(output)
39
40 // Cache for next time
41 fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true })
42 fs.writeFileSync(CACHE_FILE, output)
43 return tree
44 } catch (err) {
45 // Fall back to cache
46 if (fs.existsSync(CACHE_FILE)) {
47 console.log('codetree scan failed, using cache')
48 return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'))
49 }
50 console.error('codetree scan failed and no cache:', err.message)
51 return null
52 }
53}
54
55/**
56 * Convert codetree JSON to Scheme expressions for AtomSpace.
57 * Returns array of Scheme expression strings.
58 */
59function treeToScheme (tree) {
60 const exprs = []
61 const modules = tree.modules || {}
62 const system = tree.system || {}
63
64 // Module nodes + their files
65 for (const [modName, mod] of Object.entries(modules)) {
66 // Create module node
67 exprs.push(S.modNode(modName))
68 if (mod.version) {
69 exprs.push(S.setStringValue(S.modNode(modName), 'version', mod.version))
70 }
71 if (mod.type) {
72 exprs.push(S.setStringValue(S.modNode(modName), 'module-type', mod.type))
73 }
74
75 // Create file nodes and contains edges
76 const files = mod.files || {}
77 for (const [filePath, fileData] of Object.entries(files)) {
78 const fullPath = modName + '/' + filePath
79 const fNode = S.fileNode(fullPath)
80
81 exprs.push(fNode)
82 if (fileData.role) {
83 exprs.push(S.setStringValue(fNode, 'role', fileData.role))
84 }
85
86 // Module contains file
87 exprs.push(S.edge('contains', S.modNode(modName), fNode))
88
89 // File exports
90 if (fileData.exports) {
91 for (const exp of fileData.exports) {
92 if (exp.name && exp.name !== 'default') {
93 const fn = S.fnNode(modName + ':' + exp.name)
94 exprs.push(fn)
95 if (exp.type) {
96 exprs.push(S.setStringValue(fn, 'export-type', exp.type))
97 }
98 exprs.push(S.edge('exports', fNode, fn))
99 }
100 }
101 }
102
103 // File imports (local imports within the module or cross-module)
104 if (fileData.localImports) {
105 for (const imp of fileData.localImports) {
106 const spec = typeof imp === 'string' ? imp : (imp.specifier || imp.resolved || '')
107 if (!spec) continue
108 // Resolve relative to module
109 const resolved = typeof imp === 'object' && imp.resolved
110 ? imp.resolved
111 : spec
112 // Only track if it looks like a file path
113 if (resolved.startsWith('.') || resolved.startsWith('/')) {
114 // Normalize the import target
115 const targetPath = normalizeImportPath(modName, filePath, resolved)
116 if (targetPath) {
117 exprs.push(S.edge('imports', fNode, S.fileNode(targetPath)))
118 }
119 }
120 }
121 }
122
123 // External imports (npm packages AND cross-module deps)
124 if (fileData.imports) {
125 for (const imp of fileData.imports) {
126 const spec = imp.specifier || ''
127 // Skip relative paths and node builtins
128 if (spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('node:')) continue
129 // Package name (handle @scope/package)
130 const pkgName = spec.split('/')[0].startsWith('@')
131 ? spec.split('/').slice(0, 2).join('/')
132 : spec.split('/')[0]
133 if (!pkgName) continue
134
135 // If we scanned this package as a module, link to mod:
136 if (modules[pkgName]) {
137 exprs.push(S.edge('imports', fNode, S.modNode(pkgName)))
138 } else {
139 // External npm package — create pkg: node so we capture real boundaries
140 const pkgNode = `(ConceptNode "pkg:${pkgName.replace(/"/g, '\\"')}")`
141 exprs.push(pkgNode)
142 exprs.push(S.edge('imports', fNode, pkgNode))
143 }
144 }
145 }
146 }
147 }
148
149 // Module-level dependencies from system.dependencies
150 if (system.dependencies) {
151 for (const [mod, deps] of Object.entries(system.dependencies)) {
152 for (const dep of deps) {
153 if (modules[dep]) {
154 exprs.push(S.edge('depends-on', S.modNode(mod), S.modNode(dep)))
155 }
156 }
157 }
158 }
159
160 return exprs
161}
162
163/**
164 * Normalize an import path relative to a file in a module.
165 */
166function normalizeImportPath (modName, fromFile, importPath) {
167 if (!importPath.startsWith('.')) return null
168 const fromDir = path.dirname(fromFile)
169 let resolved = path.normalize(path.join(fromDir, importPath))
170 // Add .js if no extension
171 if (!path.extname(resolved)) resolved += '.js'
172 return modName + '/' + resolved
173}
174
175/**
176 * Ingest codetree into AtomSpace via CogServer.
177 */
178async function ingestCodetree (scanDir) {
179 const up = await isCogServerUp()
180 if (!up) {
181 console.log('[atomspace-ingest] CogServer not running, skipping codetree ingest')
182 return { ok: false, reason: 'cogserver-down' }
183 }
184
185 console.log('[atomspace-ingest] Scanning codetree...')
186 const tree = scanCodetree(scanDir)
187 if (!tree) {
188 return { ok: false, reason: 'scan-failed' }
189 }
190
191 const exprs = treeToScheme(tree)
192 console.log(`[atomspace-ingest] Generated ${exprs.length} Scheme expressions from codetree`)
193
194 // Send in batches to avoid overwhelming the TCP connection
195 const BATCH_SIZE = 50
196 let sent = 0
197 for (let i = 0; i < exprs.length; i += BATCH_SIZE) {
198 const batch = exprs.slice(i, i + BATCH_SIZE)
199 const combined = '(begin ' + batch.join(' ') + ')'
200 const result = await evalScheme(combined)
201 if (result === null) {
202 console.error(`[atomspace-ingest] CogServer connection lost at batch ${i}`)
203 return { ok: false, reason: 'connection-lost', sent }
204 }
205 sent += batch.length
206 }
207
208 console.log(`[atomspace-ingest] Codetree ingested: ${sent} atoms/edges`)
209 return { ok: true, sent, modules: Object.keys(tree.modules || {}).length }
210}
211
212// CLI entry point
213if (require.main === module) {
214 const scanDir = process.argv[2]
215 ingestCodetree(scanDir).then(result => {
216 console.log(JSON.stringify(result, null, 2))
217 process.exit(result.ok ? 0 : 1)
218 })
219}
220
221module.exports = { ingestCodetree, scanCodetree, treeToScheme }
atomspace/ingest-ratchet.js +243 -0
……@@ -0,0 +1,243 @@
1/**
2 * ingest-ratchet.js — Load ratchet correction data into AtomSpace
3 *
4 * Reads loops.json, learned-rules.json, project-maps.json and creates:
5 * - Loop nodes (loop:FINGERPRINT) with type, project, duration values
6 * - Rule nodes (rule:ID) with symptom, fix values
7 * - EdgeLinks: loop-in-file, resolved-by, rule-for-file
8 * - Danger level values on file nodes
9 *
10 * Usage:
11 * node ingest-ratchet.js
12 */
13
14const path = require('path')
15const fs = require('fs')
16const { evalScheme, isCogServerUp } = require('./cogserver')
17const S = require('./schema')
18
19const DATA_DIR = path.resolve(__dirname, '../data')
20
21function readJSON (filename) {
22 const filepath = path.join(DATA_DIR, filename)
23 if (!fs.existsSync(filepath)) return null
24 return JSON.parse(fs.readFileSync(filepath, 'utf8'))
25}
26
27/**
28 * Convert loops.json to Scheme expressions.
29 */
30function loopsToScheme (loopsData) {
31 const exprs = []
32 if (!loopsData || !loopsData.loops) return exprs
33
34 for (const loop of loopsData.loops) {
35 const fp = loop.fingerprint || loop.id || ''
36 if (!fp) continue
37
38 const node = S.loopNode(fp)
39 exprs.push(node)
40
41 if (loop.type) exprs.push(S.setStringValue(node, 'type', loop.type))
42 if (loop.project) exprs.push(S.setStringValue(node, 'project', loop.project))
43 if (loop.duration) exprs.push(S.setFloatValue(node, 'duration', loop.duration))
44 if (loop.date) exprs.push(S.setStringValue(node, 'date', loop.date))
45 exprs.push(S.setFloatValue(node, 'resolved', loop.resolved ? 1 : 0))
46
47 // Correction count
48 if (loop.corrections) {
49 exprs.push(S.setFloatValue(node, 'correction-count', loop.corrections.length))
50 }
51
52 // Files tried — create loop-in-file edges
53 if (loop.filesTried) {
54 for (const file of loop.filesTried) {
55 const fNode = S.fileNode(normalizeRatchetPath(file, loop.project))
56 exprs.push(fNode)
57 exprs.push(S.edge('loop-in-file', node, fNode))
58 }
59 }
60
61 // Resolution files — create resolved-by edges
62 if (loop.resolutionFiles) {
63 for (const file of loop.resolutionFiles) {
64 const fNode = S.fileNode(normalizeRatchetPath(file, loop.project))
65 exprs.push(fNode)
66 exprs.push(S.edge('resolved-by', node, fNode))
67 }
68 }
69 }
70
71 return exprs
72}
73
74/**
75 * Convert learned-rules.json to Scheme expressions.
76 */
77function rulesToScheme (rulesData) {
78 const exprs = []
79 if (!rulesData) return exprs
80
81 const rules = Array.isArray(rulesData) ? rulesData : (rulesData.rules || [])
82
83 for (const rule of rules) {
84 const id = rule.id || ''
85 if (!id) continue
86
87 const node = S.ruleNode(id)
88 exprs.push(node)
89
90 if (rule.quality) exprs.push(S.setStringValue(node, 'quality', rule.quality))
91
92 // Symptom text
93 if (rule.trigger && rule.trigger.symptom) {
94 exprs.push(S.setStringValue(node, 'symptom', rule.trigger.symptom.slice(0, 200)))
95 }
96
97 // Fix description
98 if (rule.action && rule.action.description) {
99 exprs.push(S.setStringValue(node, 'fix', rule.action.description.slice(0, 200)))
100 }
101
102 // Antipattern
103 if (rule.antipattern && rule.antipattern.description) {
104 exprs.push(S.setStringValue(node, 'antipattern', rule.antipattern.description.slice(0, 200)))
105 }
106
107 // Files the rule applies to
108 const ruleFiles = []
109 if (rule.trigger && rule.trigger.files) ruleFiles.push(...rule.trigger.files)
110 if (rule.action && rule.action.fixFiles) ruleFiles.push(...rule.action.fixFiles)
111
112 for (const file of [...new Set(ruleFiles)]) {
113 const project = rule.trigger ? rule.trigger.project : ''
114 const fNode = S.fileNode(normalizeRatchetPath(file, project))
115 exprs.push(fNode)
116 exprs.push(S.edge('rule-for-file', node, fNode))
117 }
118 }
119
120 return exprs
121}
122
123/**
124 * Convert project-maps.json to danger-level values on file nodes.
125 */
126function projectMapsToScheme (mapsData) {
127 const exprs = []
128 if (!mapsData || !mapsData.projects) return exprs
129
130 for (const [projectName, project] of Object.entries(mapsData.projects)) {
131 if (!project.subsystems) continue
132
133 for (const [subsysName, subsys] of Object.entries(project.subsystems)) {
134 if (!subsys.dangerLevel) continue
135
136 // Create a node for the subsystem
137 const subsysPath = projectName + '/' + subsysName
138 const fNode = S.fileNode(subsysPath)
139 exprs.push(fNode)
140 exprs.push(S.setStringValue(fNode, 'danger-level', subsys.dangerLevel))
141 exprs.push(S.setFloatValue(fNode, 'loop-count', subsys.loopCount || 0))
142 exprs.push(S.setFloatValue(fNode, 'correction-count', subsys.correctionCount || 0))
143
144 // Individual files in the subsystem
145 if (subsys.files) {
146 for (const file of subsys.files) {
147 const fileFNode = S.fileNode(normalizeRatchetPath(file, projectName))
148 exprs.push(fileFNode)
149 exprs.push(S.setStringValue(fileFNode, 'danger-level', subsys.dangerLevel))
150 // Subsystem contains file
151 exprs.push(S.edge('contains', fNode, fileFNode))
152 }
153 }
154 }
155 }
156
157 return exprs
158}
159
160/**
161 * Normalize a ratchet file path to match codetree conventions.
162 * Ratchet paths may be absolute or relative to project root.
163 */
164function normalizeRatchetPath (filePath, project) {
165 if (!filePath) return ''
166 // Strip absolute prefix if present
167 let normalized = filePath
168 .replace(/^\/storage\/projects\/code\/hypercore-kb\/hyper-fleeky\//, '')
169 .replace(/^\/storage\/projects\/code\/hypercore-kb\//, '')
170 // If project is known and path doesn't start with it, prepend
171 if (project && !normalized.startsWith(project + '/')) {
172 // Check if it's a bare filename
173 if (!normalized.includes('/')) {
174 normalized = project + '/' + normalized
175 }
176 }
177 return normalized
178}
179
180/**
181 * Ingest all ratchet data into AtomSpace.
182 */
183async function ingestRatchet () {
184 const up = await isCogServerUp()
185 if (!up) {
186 console.log('[atomspace-ingest] CogServer not running, skipping ratchet ingest')
187 return { ok: false, reason: 'cogserver-down' }
188 }
189
190 const allExprs = []
191
192 // Loops
193 const loopsData = readJSON('loops.json')
194 if (loopsData) {
195 const loopExprs = loopsToScheme(loopsData)
196 allExprs.push(...loopExprs)
197 console.log(`[atomspace-ingest] ${loopExprs.length} expressions from ${(loopsData.loops || []).length} loops`)
198 }
199
200 // Rules
201 const rulesData = readJSON('learned-rules.json')
202 if (rulesData) {
203 const ruleExprs = rulesToScheme(rulesData)
204 allExprs.push(...ruleExprs)
205 const ruleCount = Array.isArray(rulesData) ? rulesData.length : (rulesData.rules || []).length
206 console.log(`[atomspace-ingest] ${ruleExprs.length} expressions from ${ruleCount} rules`)
207 }
208
209 // Project maps
210 const mapsData = readJSON('project-maps.json')
211 if (mapsData) {
212 const mapExprs = projectMapsToScheme(mapsData)
213 allExprs.push(...mapExprs)
214 console.log(`[atomspace-ingest] ${mapExprs.length} expressions from project maps`)
215 }
216
217 // Send in batches
218 const BATCH_SIZE = 50
219 let sent = 0
220 for (let i = 0; i < allExprs.length; i += BATCH_SIZE) {
221 const batch = allExprs.slice(i, i + BATCH_SIZE)
222 const combined = '(begin ' + batch.join(' ') + ')'
223 const result = await evalScheme(combined)
224 if (result === null) {
225 console.error(`[atomspace-ingest] Connection lost at batch ${i}`)
226 return { ok: false, reason: 'connection-lost', sent }
227 }
228 sent += batch.length
229 }
230
231 console.log(`[atomspace-ingest] Ratchet data ingested: ${sent} atoms/edges`)
232 return { ok: true, sent }
233}
234
235// CLI entry point
236if (require.main === module) {
237 ingestRatchet().then(result => {
238 console.log(JSON.stringify(result, null, 2))
239 process.exit(result.ok ? 0 : 1)
240 })
241}
242
243module.exports = { ingestRatchet, loopsToScheme, rulesToScheme, projectMapsToScheme }
atomspace/ingest.js +186 -0
……@@ -0,0 +1,186 @@
1/**
2 * ingest.js — Combined ingest entry point
3 *
4 * Loads codetree structure + ratchet correction data into AtomSpace.
5 * Gracefully skips if CogServer is not running.
6 *
7 * Usage:
8 * node atomspace/ingest.js [codetree-scan-dir]
9 */
10
11const { isCogServerUp, evalScheme } = require('./cogserver')
12const { ingestCodetree } = require('./ingest-codetree')
13const { ingestRatchet } = require('./ingest-ratchet')
14const { execSync } = require('child_process')
15const path = require('path')
16
17async function ingestAll (scanDir) {
18 const up = await isCogServerUp()
19 if (!up) {
20 console.log('[atomspace-ingest] CogServer not running — skipping all ingest')
21 return { ok: false, reason: 'cogserver-down' }
22 }
23
24 const start = Date.now()
25 const results = {}
26
27 // Ingest codetree first (creates file: and mod: nodes)
28 results.codetree = await ingestCodetree(scanDir)
29
30 // Then ratchet data (creates loop: and rule: nodes, links to file: nodes)
31 results.ratchet = await ingestRatchet()
32
33 // Classify all files with language/domain/role
34 try {
35 const classifyScript = path.join(__dirname, 'classify-files.js')
36 execSync(`node ${classifyScript}`, { stdio: 'inherit', timeout: 60000 })
37 results.classify = { ok: true }
38 } catch (err) {
39 console.error('[atomspace-ingest] Classify failed:', err.message)
40 results.classify = { ok: false, error: err.message }
41 }
42
43 // Ingest session counts per module (unfamiliarity signal)
44 try {
45 results.sessions = await ingestSessionCounts()
46 } catch (err) {
47 results.sessions = { ok: false, error: err.message }
48 }
49
50 // Generate contrast-derived meta-cognition rules
51 try {
52 const contrastScript = path.join(__dirname, 'contrast-rules.js')
53 execSync(`node ${contrastScript}`, { stdio: 'inherit', timeout: 60000 })
54 results.contrasts = { ok: true }
55 } catch (err) {
56 console.error('[atomspace-ingest] Contrast rule generation failed:', err.message)
57 results.contrasts = { ok: false, error: err.message }
58 }
59
60 // Compute wisdom scores (referent_health × utility × breadth)
61 try {
62 const wisdomScript = path.join(__dirname, 'wisdom-score.js')
63 execSync(`node ${wisdomScript}`, { stdio: 'inherit', timeout: 180000 })
64 results.wisdom = { ok: true }
65 } catch (err) {
66 console.error('[atomspace-ingest] Wisdom score generation failed:', err.message)
67 results.wisdom = { ok: false, error: err.message }
68 }
69
70 // Categorize sessions (topics + continuation chains — long-term memory)
71 try {
72 const categorizeScript = path.join(__dirname, 'session-categorize.js')
73 execSync(`node ${categorizeScript}`, { stdio: 'inherit', timeout: 60000 })
74 results.categorize = { ok: true }
75 } catch (err) {
76 console.error('[atomspace-ingest] Session categorization failed:', err.message)
77 results.categorize = { ok: false, error: err.message }
78 }
79
80 // Compute advanced metrics (time-to-correction, per-domain, saturation)
81 try {
82 const advancedScript = path.join(__dirname, 'advanced-metrics.js')
83 execSync(`node ${advancedScript}`, { stdio: 'inherit', timeout: 60000 })
84 results.advanced = { ok: true }
85 } catch (err) {
86 console.error('[atomspace-ingest] Advanced metrics failed:', err.message)
87 results.advanced = { ok: false, error: err.message }
88 }
89
90 // Compute chain-level metrics (speed, complexity, productivity)
91 try {
92 const chainScript = path.join(__dirname, 'chain-analysis.js')
93 execSync(`node ${chainScript}`, { stdio: 'inherit', timeout: 60000 })
94 results.chains = { ok: true }
95 } catch (err) {
96 console.error('[atomspace-ingest] Chain analysis failed:', err.message)
97 results.chains = { ok: false, error: err.message }
98 }
99
100 // Capture success patterns (approvals + context leading to them)
101 try {
102 const successScript = path.join(__dirname, 'success-memory.js')
103 execSync(`node ${successScript}`, { stdio: 'inherit', timeout: 120000 })
104 results.successes = { ok: true }
105 } catch (err) {
106 console.error('[atomspace-ingest] Success memory failed:', err.message)
107 results.successes = { ok: false, error: err.message }
108 }
109
110 // Promote repeated successes to preferred-approach rules
111 try {
112 const promoteScript = path.join(__dirname, 'success-promote.js')
113 execSync(`node ${promoteScript}`, { stdio: 'inherit', timeout: 60000 })
114 results.promoted = { ok: true }
115 } catch (err) {
116 console.error('[atomspace-ingest] Success promotion failed:', err.message)
117 results.promoted = { ok: false, error: err.message }
118 }
119
120 // Analyze rule-type meta-patterns (which types have highest utility)
121 try {
122 const metaScript = path.join(__dirname, 'meta-patterns.js')
123 execSync(`node ${metaScript}`, { stdio: 'inherit', timeout: 30000 })
124 results.metaPatterns = { ok: true }
125 } catch (err) {
126 console.error('[atomspace-ingest] Meta patterns failed:', err.message)
127 results.metaPatterns = { ok: false, error: err.message }
128 }
129
130 const elapsed = Date.now() - start
131 console.log(`[atomspace-ingest] Total ingest: ${elapsed}ms`)
132
133 return {
134 ok: results.codetree.ok || results.ratchet.ok,
135 elapsed,
136 ...results
137 }
138}
139
140/**
141 * Ingest session counts per module — unfamiliarity signal.
142 * Reads fused-scores.json and counts distinct sessions per project cwd.
143 */
144async function ingestSessionCounts () {
145 const fs = require('fs')
146 const scoresFile = path.join(__dirname, '..', 'data', 'fused-scores.json')
147 if (!fs.existsSync(scoresFile)) return { ok: false, reason: 'no-scores-file' }
148
149 const data = JSON.parse(fs.readFileSync(scoresFile, 'utf8'))
150 const sessions = data.sessions || []
151
152 // Count sessions per module (from cwd path)
153 const modSessions = {}
154 for (const s of sessions) {
155 if (!s.cwd) continue
156 // Extract module name from cwd like /storage/projects/code/hypercore-kb/hyper-fleeky/MODULE/
157 const match = s.cwd.match(/\/hyper-fleeky\/([^/]+)/)
158 if (match) {
159 const mod = match[1]
160 modSessions[mod] = (modSessions[mod] || 0) + 1
161 }
162 }
163
164 // Store as Values on mod: atoms
165 const batch = []
166 for (const [mod, count] of Object.entries(modSessions)) {
167 const modNode = `(ConceptNode "mod:${mod.replace(/"/g, '\\"')}")`
168 batch.push(`(cog-set-value! ${modNode} (Predicate "session-count") (FloatValue ${count}))`)
169 }
170
171 if (batch.length > 0) {
172 await evalScheme('(begin ' + batch.join(' ') + ')')
173 }
174
175 return { ok: true, modules: Object.keys(modSessions).length }
176}
177
178if (require.main === module) {
179 const scanDir = process.argv[2]
180 ingestAll(scanDir).then(result => {
181 console.log(JSON.stringify(result, null, 2))
182 process.exit(result.ok ? 0 : 1)
183 })
184}
185
186module.exports = { ingestAll }
atomspace/meta-patterns.js +147 -0
……@@ -0,0 +1,147 @@
1#!/usr/bin/env node
2
3/**
4 * meta-patterns.js — Learn which KINDS of rules have highest utility
5 *
6 * For each rule/meta atom, compute:
7 * - Type (file-level rule, cross-loop pattern, preferred approach, strategic)
8 * - Average fire→no-correction rate (observed utility)
9 * - Average wisdom score
10 * - Cohort size
11 *
12 * Output: identifies which rule TYPES are most effective.
13 * Feeds back into rule generation — prefer proven-effective patterns.
14 */
15
16const fs = require('fs')
17const path = require('path')
18const { evalScheme, isCogServerUp } = require('./cogserver')
19
20const DATA_DIR = path.join(__dirname, '..', 'data')
21const FIRES_FILE = path.join(DATA_DIR, 'rule-fires.jsonl')
22const CORRECTIONS_FILE = path.join(DATA_DIR, '..', 'corrections.log')
23const UTILITY_WINDOW_SEC = 600 // 10 min after fire to detect "followed by correction"
24
25function classifyAtomType (name) {
26 if (!name) return 'unknown'
27 if (name.startsWith('rule:rule-')) return 'ratchet-auto'
28 if (name.startsWith('rule:')) return 'rule'
29 if (name.startsWith('meta:pattern:')) return 'cross-loop-pattern'
30 if (name.startsWith('meta:loop:')) return 'per-loop-contrast'
31 if (name.startsWith('preferred:')) return 'preferred-approach'
32 if (name.startsWith('success:')) return 'success-record'
33 return 'other'
34}
35
36function readFires () {
37 if (!fs.existsSync(FIRES_FILE)) return []
38 const content = fs.readFileSync(FIRES_FILE, 'utf8')
39 return content.trim().split('\n').filter(Boolean).map(l => {
40 try { return JSON.parse(l) } catch { return null }
41 }).filter(Boolean)
42}
43
44function readCorrections () {
45 if (!fs.existsSync(CORRECTIONS_FILE)) return []
46 const content = fs.readFileSync(CORRECTIONS_FILE, 'utf8')
47 const blocks = content.split('\n---\n').filter(Boolean)
48 const entries = []
49 for (const block of blocks) {
50 const lines = block.trim().split('\n')
51 const m = (lines[0] || '').match(/^(\d{4}-\d{2}-\d{2}T[\d:.]+Z)\s*\[correction\]/)
52 if (!m) continue
53 entries.push({ ts: new Date(m[1]).getTime() })
54 }
55 return entries
56}
57
58async function main () {
59 const fires = readFires()
60 const corrections = readCorrections()
61
62 if (fires.length === 0) {
63 console.log('No fire data yet — run hooks for a while first.')
64 process.exit(0)
65 }
66
67 const cutoffMs = UTILITY_WINDOW_SEC * 1000
68 const correctionTimes = corrections.map(c => c.ts).sort((a, b) => a - b)
69
70 // For each fire, determine if correction followed within window
71 const byType = {}
72 for (const fire of fires) {
73 const t = new Date(fire.timestamp).getTime()
74 const kind = classifyAtomType(fire.atom)
75 if (!byType[kind]) byType[kind] = { fires: 0, followedByCorr: 0, atoms: new Set(), wisdomSum: 0, wisdomCount: 0 }
76 byType[kind].fires++
77 if (fire.wisdom != null) { byType[kind].wisdomSum += fire.wisdom; byType[kind].wisdomCount++ }
78 byType[kind].atoms.add(fire.atom)
79
80 // Did a correction happen within window?
81 const hasFollowup = correctionTimes.some(ct => ct > t && ct <= t + cutoffMs)
82 if (hasFollowup) byType[kind].followedByCorr++
83 }
84
85 // Compute utility rate per type
86 const meta = []
87 for (const [kind, stats] of Object.entries(byType)) {
88 const cleanRate = stats.fires > 0 ? (stats.fires - stats.followedByCorr) / stats.fires : 0
89 const avgWisdom = stats.wisdomCount > 0 ? stats.wisdomSum / stats.wisdomCount : 0
90 meta.push({
91 type: kind,
92 fires: stats.fires,
93 uniqueAtoms: stats.atoms.size,
94 followedByCorrection: stats.followedByCorr,
95 cleanRate: Math.round(cleanRate * 1000) / 1000,
96 avgWisdom: Math.round(avgWisdom * 100) / 100
97 })
98 }
99 meta.sort((a, b) => b.cleanRate - a.cleanRate)
100
101 console.log('=== Rule Type Meta-Analysis ===')
102 console.log(`Total fires: ${fires.length}`)
103 console.log(`Total corrections: ${corrections.length}`)
104 console.log(`Utility window: ${UTILITY_WINDOW_SEC}s\n`)
105
106 console.log('type fires unique clean-rate avg-wisdom')
107 console.log('─'.repeat(70))
108 for (const m of meta) {
109 console.log(
110 `${m.type.padEnd(26)} ${String(m.fires).padStart(5)} ` +
111 `${String(m.uniqueAtoms).padStart(6)} ${(m.cleanRate * 100).toFixed(1).padStart(8)}% ` +
112 `${m.avgWisdom.toFixed(2).padStart(9)}`
113 )
114 }
115
116 // Export meta-patterns for feedback loop
117 const output = {
118 generated: new Date().toISOString(),
119 utilityWindowSec: UTILITY_WINDOW_SEC,
120 totalFires: fires.length,
121 totalCorrections: corrections.length,
122 byType: meta
123 }
124 fs.writeFileSync(path.join(DATA_DIR, 'meta-patterns.json'), JSON.stringify(output, null, 2))
125
126 // Ingest as atoms
127 if (await isCogServerUp()) {
128 const exprs = []
129 for (const m of meta) {
130 const node = `(ConceptNode "meta-type:${m.type}")`
131 exprs.push(node)
132 exprs.push(`(cog-set-value! ${node} (Predicate "clean-rate") (FloatValue ${m.cleanRate}))`)
133 exprs.push(`(cog-set-value! ${node} (Predicate "fires") (FloatValue ${m.fires}))`)
134 exprs.push(`(cog-set-value! ${node} (Predicate "avg-wisdom") (FloatValue ${m.avgWisdom}))`)
135 }
136 if (exprs.length > 0) {
137 await evalScheme('(begin ' + exprs.join(' ') + ')')
138 console.log(`\nStored ${exprs.length} meta-pattern atoms.`)
139 }
140 }
141}
142
143if (require.main === module) {
144 main().catch(err => { console.error(err.message); process.exit(1) })
145}
146
147module.exports = { classifyAtomType }
atomspace/query.js +757 -0
……@@ -0,0 +1,757 @@
1/**
2 * query.js — AtomSpace queries for code intelligence
3 *
4 * Three query functions that improve Claude's responses:
5 * blastRadius(filePath) — what's at risk if I edit this file?
6 * similarLoops(editedFiles) — what past loops match this situation?
7 * riskAssessment(editList) — aggregate risk across all edited files
8 *
9 * All functions return null if CogServer is not running (graceful fallback).
10 */
11
12const { evalScheme, isCogServerUp } = require('./cogserver')
13const S = require('./schema')
14
15/**
16 * Parse a MeetLink result string into an array of atom names.
17 * MeetLink returns: (SetLink (ConceptNode "x") (ConceptNode "y") ...)
18 * or just: (ConceptNode "x") for single results
19 */
20function parseAtomNames (resultStr) {
21 if (!resultStr) return []
22 const names = []
23 // CogServer prints either "ConceptNode" or abbreviated "Concept"
24 const re = /(?:ConceptNode|Concept) "([^"]+)"/g
25 let match
26 while ((match = re.exec(resultStr)) !== null) {
27 names.push(match[1])
28 }
29 return names
30}
31
32/**
33 * Parse a float value result.
34 */
35function parseFloat_ (resultStr) {
36 if (!resultStr) return 0
37 const match = resultStr.match(/[\d.]+/)
38 return match ? parseFloat(match[0]) : 0
39}
40
41/**
42 * blastRadius(filePath) — What's at risk if I edit this file?
43 *
44 * Returns: {
45 * consumers: string[], // files that import from this file
46 * dependencies: string[], // files this file imports from
47 * loops: string[], // loop fingerprints involving this file
48 * rules: string[], // rule IDs applying to this file
49 * dangerLevel: string, // "low"|"medium"|"high"
50 * module: string, // which module this file belongs to
51 * }
52 */
53async function blastRadius (filePath) {
54 if (!await isCogServerUp()) return null
55
56 // Normalize path — strip leading slashes, common prefixes
57 const normalPath = filePath
58 .replace(/^\/storage\/projects\/code\/hypercore-kb\/hyper-fleeky\//, '')
59 .replace(/^\/storage\/projects\/code\/hypercore-kb\//, '')
60
61 const [consumers, dependencies, loops, rules, danger] = await Promise.all([
62 evalScheme(S.queryImporters(normalPath)),
63 evalScheme(S.queryDependencies(normalPath)),
64 evalScheme(S.queryLoopsForFile(normalPath)),
65 evalScheme(S.queryRulesForFile(normalPath)),
66 evalScheme(S.getStringValue(S.fileNode(normalPath), 'danger-level'))
67 ])
68
69 // Sort rules by wisdom score (high-wisdom first), filter out zero-wisdom
70 const ruleNames = parseAtomNames(rules)
71 const rulesWithWisdom = await Promise.all(ruleNames.map(async r => ({
72 name: r,
73 wisdom: await getWisdom(r)
74 })))
75 // Keep rules even if wisdom is 0 — still visible, just ranked lower
76 const rankedRules = rulesWithWisdom
77 .sort((a, b) => b.wisdom - a.wisdom)
78 .map(r => r.name)
79
80 return {
81 file: normalPath,
82 consumers: parseAtomNames(consumers),
83 dependencies: parseAtomNames(dependencies),
84 loops: parseAtomNames(loops),
85 rules: rankedRules, // sorted by wisdom
86 rulesHighWisdom: rulesWithWisdom.filter(r => r.wisdom >= 1.0).length,
87 rulesLowWisdom: rulesWithWisdom.filter(r => r.wisdom < 0.3).length,
88 dangerLevel: (danger && danger !== '""' && danger !== '') ? danger.replace(/"/g, '') : 'unknown'
89 }
90}
91
92/**
93 * Get the wisdom score for a rule or meta atom.
94 * Returns 0.5 if no wisdom value stored (neutral default).
95 */
96async function getWisdom (atomName) {
97 const result = await evalScheme(
98 `(let ((v (cog-value (ConceptNode "${atomName.replace(/"/g, '\\"')}") (Predicate "wisdom")))) (if v (cog-value-ref v 0) 0.5))`)
99 return parseFloat(result) || 0.5
100}
101
102/**
103 * similarLoops(editedFiles) — What past loops match this situation?
104 *
105 * Finds loops that share file overlap with the currently edited files.
106 * Scored by overlap count.
107 *
108 * Returns: {
109 * matches: [{ loopId, sharedFiles, score, type, project, resolved }]
110 * }
111 */
112async function similarLoops (editedFiles) {
113 if (!await isCogServerUp()) return null
114 if (!editedFiles || !editedFiles.length) return { matches: [] }
115
116 // For each edited file, find loops
117 const allLoops = new Map() // loopId → { sharedFiles, type, project, resolved }
118
119 for (const file of editedFiles) {
120 const normalPath = file
121 .replace(/^\/storage\/projects\/code\/hypercore-kb\/hyper-fleeky\//, '')
122 .replace(/^\/storage\/projects\/code\/hypercore-kb\//, '')
123
124 const result = await evalScheme(S.queryLoopsForFile(normalPath))
125 const loopNames = parseAtomNames(result)
126
127 for (const loopName of loopNames) {
128 const id = loopName.replace('loop:', '')
129 if (!allLoops.has(id)) {
130 // Get loop metadata
131 const node = S.loopNode(id)
132 const [type, project, resolved] = await Promise.all([
133 evalScheme(S.getStringValue(node, 'type')),
134 evalScheme(S.getStringValue(node, 'project')),
135 evalScheme(S.getFloatValue(node, 'resolved'))
136 ])
137 allLoops.set(id, {
138 loopId: id,
139 sharedFiles: [normalPath],
140 type: (type || '').replace(/"/g, ''),
141 project: (project || '').replace(/"/g, ''),
142 resolved: parseFloat_(resolved) > 0.5
143 })
144 } else {
145 allLoops.get(id).sharedFiles.push(normalPath)
146 }
147 }
148 }
149
150 // Score by shared file count and sort
151 const matches = [...allLoops.values()]
152 .map(m => ({ ...m, score: m.sharedFiles.length }))
153 .sort((a, b) => b.score - a.score)
154 .slice(0, 10)
155
156 return { matches }
157}
158
159/**
160 * riskAssessment(editList) — Aggregate risk across all edited files
161 *
162 * Returns: {
163 * risk: "low"|"medium"|"high",
164 * totalDownstream: number,
165 * totalLoops: number,
166 * totalRules: number,
167 * crossModuleEdits: string[],
168 * hotspots: [{ file, consumers, loops, danger }]
169 * }
170 */
171async function riskAssessment (editList) {
172 if (!await isCogServerUp()) return null
173 if (!editList || !editList.length) return { risk: 'low', totalDownstream: 0, totalLoops: 0, totalRules: 0, crossModuleEdits: [], hotspots: [] }
174
175 const hotspots = []
176 let totalDownstream = 0
177 let totalLoops = 0
178 let totalRules = 0
179 const modules = new Set()
180 const crossModuleEdits = []
181
182 for (const file of editList) {
183 const br = await blastRadius(file)
184 if (!br) continue
185
186 // Track module boundaries
187 const parts = br.file.split('/')
188 if (parts.length > 0) {
189 const mod = parts[0]
190 if (modules.size > 0 && !modules.has(mod)) {
191 crossModuleEdits.push(br.file)
192 }
193 modules.add(mod)
194 }
195
196 totalDownstream += br.consumers.length
197 totalLoops += br.loops.length
198 totalRules += br.rules.length
199
200 if (br.consumers.length > 0 || br.loops.length > 0 || br.dangerLevel !== 'unknown') {
201 hotspots.push({
202 file: br.file,
203 consumers: br.consumers.length,
204 loops: br.loops.length,
205 danger: br.dangerLevel
206 })
207 }
208 }
209
210 // Determine overall risk
211 let risk = 'low'
212 if (totalLoops > 3 || crossModuleEdits.length > 0 || hotspots.some(h => h.danger === 'high')) {
213 risk = 'high'
214 } else if (totalLoops > 0 || totalDownstream > 5 || hotspots.some(h => h.danger === 'medium')) {
215 risk = 'medium'
216 }
217
218 return {
219 risk,
220 totalDownstream,
221 totalLoops,
222 totalRules,
223 crossModuleEdits,
224 hotspots: hotspots.sort((a, b) => (b.loops + b.consumers) - (a.loops + a.consumers)).slice(0, 5)
225 }
226}
227
228/**
229 * Format a blast radius result as a human-readable briefing string.
230 */
231function formatBlastRadius (br) {
232 if (!br) return ''
233 const lines = []
234 if (br.consumers.length) lines.push(`Downstream consumers (${br.consumers.length}): ${br.consumers.slice(0, 5).map(c => c.replace('file:', '')).join(', ')}`)
235 if (br.loops.length) lines.push(`Past correction loops (${br.loops.length}): ${br.loops.slice(0, 3).join(', ')}`)
236 if (br.rules.length) lines.push(`Applicable rules: ${br.rules.length}`)
237 if (br.dangerLevel !== 'unknown') lines.push(`Danger level: ${br.dangerLevel}`)
238 return lines.join('\n')
239}
240
241/**
242 * fileContext(filePath) — Language/domain/role-aware context for a file
243 *
244 * Returns: {
245 * file, language, domain, role,
246 * exports: number, // count of things this file exports
247 * isExportedAPI: bool, // true if file has exports
248 * avgLoopsForLanguage: number, // correction density for this language
249 * dominantLoopType: string, // most common failure mode
250 * }
251 */
252async function fileContext (filePath) {
253 if (!await isCogServerUp()) return null
254
255 const normalPath = filePath
256 .replace(/^\/storage\/projects\/code\/hypercore-kb\/hyper-fleeky\//, '')
257 .replace(/^\/storage\/projects\/code\/hypercore-kb\//, '')
258
259 const [language, domain, role, exportStr] = await Promise.all([
260 evalScheme(S.getStringValue(S.fileNode(normalPath), 'language')),
261 evalScheme(S.getStringValue(S.fileNode(normalPath), 'domain')),
262 evalScheme(S.getStringValue(S.fileNode(normalPath), 'role')),
263 evalScheme(`(length (cog-execute! (Meet (TypedVariable (Variable "$e") (Type (quote ConceptNode))) (Present (EdgeLink (BondNode "exports") (ListLink (ConceptNode "file:${normalPath}") (Variable "$e")))))))`)
264 ])
265
266 const cleanStr = (s) => (s || '').replace(/"/g, '').trim()
267 const lang = cleanStr(language) || 'unknown'
268 const dom = cleanStr(domain) || 'unknown'
269 const rl = cleanStr(role) || 'unknown'
270 const exports = parseInt(exportStr) || 0
271
272 return {
273 file: normalPath,
274 language: lang,
275 domain: dom,
276 role: rl,
277 exports,
278 isExportedAPI: exports > 0
279 }
280}
281
282/**
283 * findResolution(filePath, loopType) — What fixed similar past loops?
284 *
285 * Queries loops matching this file's language+domain+type. Returns
286 * the resolution files from those loops — "last time, the fix was in X."
287 *
288 * Returns: {
289 * matches: [{ loopId, project, resolutionFiles, corrections }]
290 * }
291 */
292async function findResolution (filePath, loopType) {
293 if (!await isCogServerUp()) return null
294
295 const ctx = await fileContext(filePath)
296 if (!ctx) return null
297
298 // Find loops in the same domain with similar type
299 // (We can't query by language/domain on loops directly — they're on files)
300 // So: find loops in files that share language+domain with this one
301
302 await evalScheme('(use-modules (srfi srfi-1))')
303
304 // Get all loops that touch files in the same domain
305 const allLoopsStr = await evalScheme(
306 '(string-join (map cog-name (cog-get-atoms (quote ConceptNode))) "|")')
307 const allAtoms = (allLoopsStr || '').replace(/^"|"$/g, '').split('|').filter(s => s.startsWith('loop:'))
308
309 const matches = []
310 for (const loopName of allAtoms.slice(0, 100)) { // limit for performance
311 // Get loop's type and project
312 const [type, project, resolved] = await Promise.all([
313 evalScheme(`(let ((v (cog-value (ConceptNode "${loopName}") (Predicate "type")))) (if v (cog-value-ref v 0) ""))`),
314 evalScheme(`(let ((v (cog-value (ConceptNode "${loopName}") (Predicate "project")))) (if v (cog-value-ref v 0) ""))`),
315 evalScheme(`(let ((v (cog-value (ConceptNode "${loopName}") (Predicate "resolved")))) (if v (cog-value-ref v 0) 0))`)
316 ])
317
318 const cleanType = (type || '').replace(/"/g, '')
319 const cleanProject = (project || '').replace(/"/g, '')
320 const isResolved = parseFloat(resolved) > 0.5
321
322 // Match if type matches or project matches
323 if (!isResolved) continue
324 if (loopType && cleanType !== loopType) continue
325
326 // Get resolution files
327 const resolutionStr = await evalScheme(
328 `(cog-execute! (Meet (TypedVariable (Variable "$f") (Type (quote ConceptNode))) (Present (EdgeLink (BondNode "resolved-by") (ListLink (ConceptNode "${loopName}") (Variable "$f"))))))`)
329 const resolutionFiles = parseAtomNames(resolutionStr)
330
331 if (resolutionFiles.length > 0) {
332 matches.push({
333 loopId: loopName.replace('loop:', ''),
334 type: cleanType,
335 project: cleanProject,
336 resolutionFiles: resolutionFiles.map(f => f.replace('file:', ''))
337 })
338 }
339 }
340
341 return {
342 context: ctx,
343 matches: matches.slice(0, 5)
344 }
345}
346
347/**
348 * unfamiliarityScore(filePath) — How much experience do I have with this module?
349 *
350 * Queries session-count on the parent module. Low session count + high
351 * correction density = unfamiliarity risk.
352 */
353async function unfamiliarityScore (filePath) {
354 if (!await isCogServerUp()) return null
355
356 const ctx = await fileContext(filePath)
357 if (!ctx) return null
358
359 // Extract module from path (first segment)
360 const modName = ctx.file.split('/')[0]
361 if (!modName) return null
362
363 const sessionStr = await evalScheme(
364 `(let ((v (cog-value (ConceptNode "mod:${modName}") (Predicate "session-count")))) (if v (cog-value-ref v 0) 0))`)
365 const sessions = parseFloat(sessionStr) || 0
366
367 let level = 'unknown'
368 if (sessions === 0) level = 'never-visited'
369 else if (sessions < 3) level = 'unfamiliar'
370 else if (sessions < 10) level = 'familiar'
371 else level = 'well-known'
372
373 return { module: modName, sessions, familiarity: level }
374}
375
376/**
377 * Enhanced blastRadius — adds export weighting
378 */
379async function blastRadiusEnhanced (filePath) {
380 const br = await blastRadius(filePath)
381 if (!br) return null
382
383 const ctx = await fileContext(filePath)
384 const unfam = await unfamiliarityScore(filePath)
385
386 // Compute weighted risk score
387 let riskScore = br.loops.length * 3 + br.rules.length + br.consumers.length * 2
388 if (ctx && ctx.isExportedAPI) riskScore += 5 // exports amplify blast radius
389 if (unfam && (unfam.familiarity === 'unfamiliar' || unfam.familiarity === 'never-visited')) riskScore += 3
390
391 return {
392 ...br,
393 ...ctx,
394 ...unfam,
395 riskScore
396 }
397}
398
399/**
400 * contrastGuidance(filePath) — Contrast-derived meta-cognition for a file
401 *
402 * Finds meta-cognition atoms where this file is either in the approach-works
403 * or approach-fails position. Also finds cross-loop patterns for the file's
404 * project+language context.
405 *
406 * Returns: {
407 * worksFor: [{ loopId, context, correction }], // past cases where this file fixed things
408 * failedFor: [{ loopId, context, correction }], // past cases where editing this file was wasted
409 * patterns: [{ context, tryFirst: [files], avoidFirst: [files], loopCount }]
410 * }
411 */
412async function contrastGuidance (filePath) {
413 if (!await isCogServerUp()) return null
414
415 const normalPath = filePath
416 .replace(/^\/storage\/projects\/code\/hypercore-kb\/hyper-fleeky\//, '')
417 .replace(/^\/storage\/projects\/code\/hypercore-kb\//, '')
418 .replace(/^\/storage\/projects\/code\//, '')
419
420 const fileAtom = `(ConceptNode "file:${normalPath.replace(/"/g, '\\"')}")`
421
422 // Find meta-atoms where this file is a successful approach
423 const worksForStr = await evalScheme(
424 `(cog-execute! (Meet (TypedVariable (Variable "$m") (Type (quote ConceptNode))) (Present (EdgeLink (BondNode "approach-works") (ListLink (Variable "$m") ${fileAtom})))))`)
425
426 // Find meta-atoms where this file was wasted effort
427 const failedForStr = await evalScheme(
428 `(cog-execute! (Meet (TypedVariable (Variable "$m") (Type (quote ConceptNode))) (Present (EdgeLink (BondNode "approach-fails") (ListLink (Variable "$m") ${fileAtom})))))`)
429
430 const worksMetaIds = parseAtomNames(worksForStr).filter(n => n.startsWith('meta:loop:'))
431 const failedMetaIds = parseAtomNames(failedForStr).filter(n => n.startsWith('meta:loop:'))
432
433 // Get context info for each meta-atom
434 async function getMetaContext (metaId) {
435 const [project, loopType, correction] = await Promise.all([
436 evalScheme(`(let ((v (cog-value (ConceptNode "${metaId}") (Predicate "project")))) (if v (cog-value-ref v 0) ""))`),
437 evalScheme(`(let ((v (cog-value (ConceptNode "${metaId}") (Predicate "loop-type")))) (if v (cog-value-ref v 0) ""))`),
438 evalScheme(`(let ((v (cog-value (ConceptNode "${metaId}") (Predicate "correction-snippet")))) (if v (cog-value-ref v 0) ""))`)
439 ])
440 return {
441 loopId: metaId.replace('meta:loop:', ''),
442 project: (project || '').replace(/"/g, ''),
443 loopType: (loopType || '').replace(/"/g, ''),
444 correction: (correction || '').replace(/"/g, '')
445 }
446 }
447
448 const worksFor = await Promise.all(worksMetaIds.slice(0, 5).map(getMetaContext))
449 const failedFor = await Promise.all(failedMetaIds.slice(0, 5).map(getMetaContext))
450
451 // Find cross-loop patterns whose context applies to this file
452 // Need the file's language + the project it's in
453 const fileCtx = await fileContext(filePath)
454
455 const patterns = []
456 if (fileCtx && fileCtx.language && fileCtx.language !== 'unknown') {
457 // Extract project from path
458 const parts = normalPath.split('/')
459 const project = parts[0]
460
461 // Query patterns matching this context
462 const patternKeys = [
463 `meta:pattern:${project}:${fileCtx.language}:*`, // broad
464 ]
465
466 for (const key of patternKeys) {
467 const patternNode = `(ConceptNode "${key}")`
468 const existsStr = await evalScheme(`(cog-node (quote ConceptNode) "${key}")`)
469 if (!existsStr || existsStr === 'false' || existsStr === '#f') continue
470
471 const tryFirstStr = await evalScheme(
472 `(cog-execute! (Meet (TypedVariable (Variable "$f") (Type (quote ConceptNode))) (Present (EdgeLink (BondNode "try-first") (ListLink ${patternNode} (Variable "$f"))))))`)
473 const avoidStr = await evalScheme(
474 `(cog-execute! (Meet (TypedVariable (Variable "$f") (Type (quote ConceptNode))) (Present (EdgeLink (BondNode "avoid-first") (ListLink ${patternNode} (Variable "$f"))))))`)
475 const loopCountStr = await evalScheme(
476 `(let ((v (cog-value ${patternNode} (Predicate "loop-count")))) (if v (cog-value-ref v 0) 0))`)
477
478 const tryFirst = parseAtomNames(tryFirstStr).map(n => n.replace('file:', ''))
479 const avoidFirst = parseAtomNames(avoidStr).map(n => n.replace('file:', ''))
480
481 if (tryFirst.length > 0 || avoidFirst.length > 0) {
482 patterns.push({
483 context: key.replace('meta:pattern:', ''),
484 tryFirst,
485 avoidFirst,
486 loopCount: parseFloat(loopCountStr) || 0
487 })
488 }
489 }
490 }
491
492 return {
493 file: normalPath,
494 worksFor: worksFor.filter(Boolean),
495 failedFor: failedFor.filter(Boolean),
496 patterns
497 }
498}
499
500/**
501 * liveSessionContext(filePath) — Live events in the current session for a file
502 *
503 * Returns: {
504 * sessionId,
505 * sessionEditedFiles: number, // how many files edited this session
506 * editsForThisFile: number, // times this file edited this session
507 * correctionsNearThisFile: number, // corrections that happened near edits to this file
508 * recentCorrections: [{ text, turn }] // sample corrections
509 * }
510 *
511 * If no active session, returns null.
512 */
513async function liveSessionContext (filePath) {
514 if (!await isCogServerUp()) return null
515
516 const fs = require('fs')
517 let sessionId = null
518 try { sessionId = fs.readFileSync('/tmp/claude-atomspace-session', 'utf8').trim() } catch {}
519 if (!sessionId) return null
520
521 const normalPath = filePath
522 .replace(/^\/storage\/projects\/code\/hypercore-kb\/hyper-fleeky\//, '')
523 .replace(/^\/storage\/projects\/code\/hypercore-kb\//, '')
524 .replace(/^\/storage\/projects\/code\//, '')
525
526 const sessionNode = `(ConceptNode "session:${sessionId}")`
527 const fileAtom = `(ConceptNode "file:${normalPath.replace(/"/g, '\\"')}")`
528
529 // Files edited this session
530 const editedStr = await evalScheme(
531 `(cog-execute! (Meet (TypedVariable (Variable "$f") (Type (quote ConceptNode))) (Present (EdgeLink (BondNode "session-edited") (ListLink ${sessionNode} (Variable "$f"))))))`)
532 const editedFiles = parseAtomNames(editedStr)
533
534 // Is THIS file in the session-edited set?
535 const isThisEdited = editedFiles.includes(`file:${normalPath}`)
536 const fileEdits = isThisEdited ? [fileAtom] : []
537
538 // All corrections in the session
539 const allCorrectionsStr = await evalScheme(
540 `(cog-execute! (Meet (TypedVariable (Variable "$c") (Type (quote ConceptNode))) (Present (EdgeLink (BondNode "correction-in-session") (ListLink (Variable "$c") ${sessionNode})))))`)
541 const allCorrectionIds = parseAtomNames(allCorrectionsStr)
542
543 // Filter to corrections near edit of this specific file
544 const correctionIds = []
545 for (const cid of allCorrectionIds) {
546 const nearEdit = await evalScheme(
547 `(cog-link (quote EdgeLink) (BondNode "correction-near-edit") (ListLink (ConceptNode "${cid}") ${fileAtom}))`)
548 if (nearEdit && nearEdit !== 'false' && !nearEdit.includes('#f')) {
549 correctionIds.push(cid)
550 }
551 }
552
553 // Get text for each correction
554 const recentCorrections = []
555 for (const cid of correctionIds.slice(0, 3)) {
556 const [text, turn] = await Promise.all([
557 evalScheme(`(let ((v (cog-value (ConceptNode "${cid}") (Predicate "text")))) (if v (cog-value-ref v 0) ""))`),
558 evalScheme(`(let ((v (cog-value (ConceptNode "${cid}") (Predicate "turn")))) (if v (cog-value-ref v 0) 0))`)
559 ])
560 recentCorrections.push({
561 text: (text || '').replace(/"/g, '').slice(0, 150),
562 turn: parseFloat(turn) || 0
563 })
564 }
565
566 return {
567 sessionId,
568 sessionEditedFiles: editedFiles.length,
569 editsForThisFile: fileEdits.length,
570 correctionsNearThisFile: correctionIds.length,
571 totalSessionCorrections: allCorrectionIds.length,
572 recentCorrections
573 }
574}
575
576/**
577 * relevantAtoms(context) — Top-k wisdom-ranked meta-cognition for current context
578 *
579 * context: {
580 * cwd?: string, // current working directory
581 * files?: string[], // files mentioned in prompt or recently edited
582 * module?: string, // module name from cwd
583 * language?: string, // inferred language of work area
584 * }
585 *
586 * Returns: {
587 * tier1: [{ atom, wisdom, summary }], // top 3, shown prominently
588 * tier2: [{ atom, wisdom, summary }], // next 2, abbreviated
589 * totalMatched: number, // how many matched total
590 * totalTokens: number // estimated tokens in all briefing
591 * }
592 */
593async function relevantAtoms (context) {
594 if (!await isCogServerUp()) return null
595
596 const files = context.files || []
597 const module = context.module
598 const language = context.language
599
600 const candidates = new Map() // atomName → { atom, wisdom, matchReason }
601
602 // 1. Rules and meta atoms directly for the listed files
603 for (const file of files) {
604 const normalPath = file
605 .replace(/^\/storage\/projects\/code\/hypercore-kb\/hyper-fleeky\//, '')
606 .replace(/^\/storage\/projects\/code\/hypercore-kb\//, '')
607 .replace(/^\/storage\/projects\/code\//, '')
608 if (!normalPath) continue
609 const fileAtom = `(ConceptNode "file:${normalPath.replace(/"/g, '\\"')}")`
610
611 // Rules for this file
612 const ruleResult = await evalScheme(
613 `(cog-execute! (Meet (TypedVariable (Variable "$r") (Type (quote ConceptNode))) (Present (EdgeLink (BondNode "rule-for-file") (ListLink (Variable "$r") ${fileAtom})))))`)
614 for (const name of parseAtomNames(ruleResult)) {
615 if (!name.startsWith('rule:')) continue
616 if (!candidates.has(name)) candidates.set(name, { atom: name, matchReason: `file: ${normalPath}` })
617 }
618
619 // Per-loop meta atoms linked via approach-works or approach-fails
620 const metaResult = await evalScheme(
621 `(cog-execute! (Meet (TypedVariable (Variable "$m") (Type (quote ConceptNode))) (Present (EdgeLink (BondNode "approach-works") (ListLink (Variable "$m") ${fileAtom})))))`)
622 for (const name of parseAtomNames(metaResult)) {
623 if (!name.startsWith('meta:')) continue
624 if (!candidates.has(name)) candidates.set(name, { atom: name, matchReason: `approach-works for ${normalPath}` })
625 }
626 }
627
628 // 2. Cross-loop patterns for the module + language
629 if (module && language && language !== 'unknown') {
630 const patternKeys = [
631 `meta:pattern:${module}:${language}:*`, // broad
632 ]
633 for (const key of patternKeys) {
634 const existsStr = await evalScheme(`(cog-node (quote ConceptNode) "${key}")`)
635 if (!existsStr || existsStr === 'false' || existsStr === '#f' || !existsStr.includes('Concept')) continue
636 if (!candidates.has(key)) candidates.set(key, { atom: key, matchReason: `pattern for ${module}:${language}` })
637 }
638 }
639
640 // 3. Fetch wisdom for all candidates, sort
641 const scored = []
642 for (const [name, info] of candidates) {
643 const wisdom = await getWisdom(name)
644 scored.push({ ...info, wisdom })
645 }
646 scored.sort((a, b) => b.wisdom - a.wisdom)
647
648 // 4. Fetch human-readable summaries for top candidates
649 async function getSummary (atomName) {
650 const node = `(ConceptNode "${atomName.replace(/"/g, '\\"')}")`
651 if (atomName.startsWith('rule:')) {
652 const [symptom, fix] = await Promise.all([
653 evalScheme(`(let ((v (cog-value ${node} (Predicate "symptom")))) (if v (cog-value-ref v 0) ""))`),
654 evalScheme(`(let ((v (cog-value ${node} (Predicate "fix")))) (if v (cog-value-ref v 0) ""))`)
655 ])
656 const s = (symptom || '').replace(/"/g, '').trim()
657 const f = (fix || '').replace(/"/g, '').trim()
658 if (f && s) return `When: ${s.slice(0, 100)} → fix: ${f.slice(0, 100)}`
659 if (f) return `Fix pattern: ${f.slice(0, 150)}`
660 return 'Rule (no description)'
661 }
662 if (atomName.startsWith('meta:loop:')) {
663 const correction = await evalScheme(`(let ((v (cog-value ${node} (Predicate "correction-snippet")))) (if v (cog-value-ref v 0) ""))`)
664 return `Past correction: "${(correction || '').replace(/"/g, '').slice(0, 100)}..."`
665 }
666 if (atomName.startsWith('meta:pattern:')) {
667 // Get try-first and avoid-first files
668 const tryStr = await evalScheme(
669 `(cog-execute! (Meet (TypedVariable (Variable "$f") (Type (quote ConceptNode))) (Present (EdgeLink (BondNode "try-first") (ListLink ${node} (Variable "$f"))))))`)
670 const tryFiles = parseAtomNames(tryStr).map(n => n.replace('file:', '').split('/').pop()).slice(0, 2)
671 const avoidStr = await evalScheme(
672 `(cog-execute! (Meet (TypedVariable (Variable "$f") (Type (quote ConceptNode))) (Present (EdgeLink (BondNode "avoid-first") (ListLink ${node} (Variable "$f"))))))`)
673 const avoidFiles = parseAtomNames(avoidStr).map(n => n.replace('file:', '').split('/').pop()).slice(0, 2)
674 const ctx = atomName.replace('meta:pattern:', '')
675 let parts = []
676 if (tryFiles.length) parts.push(`try ${tryFiles.join(', ')}`)
677 if (avoidFiles.length) parts.push(`avoid ${avoidFiles.join(', ')}`)
678 return `[${ctx}] ${parts.join('; ')}`
679 }
680 return atomName
681 }
682
683 const totalMatched = scored.length
684 // Tier 1: top 3 by wisdom
685 const tier1 = []
686 for (const c of scored.slice(0, 3)) {
687 tier1.push({ ...c, summary: await getSummary(c.atom) })
688 }
689 // Tier 2: next 2
690 const tier2 = []
691 for (const c of scored.slice(3, 5)) {
692 tier2.push({ ...c, summary: await getSummary(c.atom) })
693 }
694
695 // Estimate tokens (rough: 1 token ≈ 4 chars)
696 const totalChars = [...tier1, ...tier2].reduce((acc, c) => acc + (c.summary || '').length, 0)
697 const totalTokens = Math.ceil(totalChars / 4)
698
699 return { tier1, tier2, totalMatched, totalTokens }
700}
701
702/**
703 * preferredApproachFor(filePath) — Find preferred approach rules for editing this file
704 *
705 * Returns: [{ id, shape, successCount, sessionCount, avgScore, secondaryFiles, sampleApproval }]
706 * Sorted by successCount desc.
707 */
708async function preferredApproachFor (filePath) {
709 if (!await isCogServerUp()) return null
710
711 const normalPath = filePath
712 .replace(/^\/storage\/projects\/code\/hypercore-kb\/hyper-fleeky\//, '')
713 .replace(/^\/storage\/projects\/code\/hypercore-kb\//, '')
714 .replace(/^\/storage\/projects\/code\//, '')
715
716 const fileAtom = `(ConceptNode "file:${normalPath.replace(/"/g, '\\"')}")`
717
718 const result = await evalScheme(
719 `(cog-execute! (Meet (TypedVariable (Variable "$p") (Type (quote ConceptNode))) (Present (EdgeLink (BondNode "preferred-for-file") (ListLink (Variable "$p") ${fileAtom})))))`)
720
721 const approachIds = parseAtomNames(result).filter(n => n.startsWith('preferred:'))
722 const approaches = []
723 for (const pid of approachIds.slice(0, 5)) {
724 const node = `(ConceptNode "${pid.replace(/"/g, '\\"')}")`
725 const [shape, count, avgScore, sample] = await Promise.all([
726 evalScheme(`(let ((v (cog-value ${node} (Predicate "shape")))) (if v (cog-value-ref v 0) ""))`),
727 evalScheme(`(let ((v (cog-value ${node} (Predicate "success-count")))) (if v (cog-value-ref v 0) 0))`),
728 evalScheme(`(let ((v (cog-value ${node} (Predicate "avg-score")))) (if v (cog-value-ref v 0) 0))`),
729 evalScheme(`(let ((v (cog-value ${node} (Predicate "sample-approval")))) (if v (cog-value-ref v 0) ""))`)
730 ])
731 approaches.push({
732 id: pid,
733 shape: (shape || '').replace(/"/g, ''),
734 successCount: parseFloat(count) || 0,
735 avgScore: parseFloat(avgScore) || 0,
736 sampleApproval: (sample || '').replace(/"/g, '').slice(0, 100)
737 })
738 }
739 approaches.sort((a, b) => b.successCount - a.successCount)
740 return approaches
741}
742
743module.exports = {
744 blastRadius,
745 blastRadiusEnhanced,
746 similarLoops,
747 riskAssessment,
748 formatBlastRadius,
749 fileContext,
750 findResolution,
751 unfamiliarityScore,
752 contrastGuidance,
753 liveSessionContext,
754 relevantAtoms,
755 getWisdom,
756 preferredApproachFor
757}
atomspace/review-rule-candidates.js +145 -0
……@@ -0,0 +1,145 @@
1#!/usr/bin/env node
2
3/**
4 * review-rule-candidates.js — Interactive review of proposed rules
5 *
6 * Reads data/rule-candidates.jsonl (written by stream-correction on corrections).
7 * For each pending candidate: show symptom, trigger files, suggested rule.
8 * User accepts, refines, or rejects.
9 * Accepted rules are appended to data/learned-rules.json (or stored as atoms).
10 *
11 * Usage:
12 * node atomspace/review-rule-candidates.js # review all pending
13 * node atomspace/review-rule-candidates.js --list # list without reviewing
14 * node atomspace/review-rule-candidates.js --clear # clear all pending (reject all)
15 */
16
17const fs = require('fs')
18const path = require('path')
19const readline = require('readline')
20
21const DATA_DIR = path.join(__dirname, '..', 'data')
22const CANDIDATES_FILE = path.join(DATA_DIR, 'rule-candidates.jsonl')
23const LEARNED_RULES_FILE = path.join(DATA_DIR, 'learned-rules.json')
24const ARCHIVE_FILE = path.join(DATA_DIR, 'rule-candidates-archive.jsonl')
25
26function readCandidates () {
27 if (!fs.existsSync(CANDIDATES_FILE)) return []
28 const content = fs.readFileSync(CANDIDATES_FILE, 'utf8')
29 return content.trim().split('\n').filter(Boolean).map(l => {
30 try { return JSON.parse(l) } catch { return null }
31 }).filter(Boolean)
32}
33
34function writeCandidates (candidates) {
35 fs.writeFileSync(CANDIDATES_FILE, candidates.map(c => JSON.stringify(c)).join('\n') + (candidates.length > 0 ? '\n' : ''))
36}
37
38function archive (candidate) {
39 fs.appendFileSync(ARCHIVE_FILE, JSON.stringify(candidate) + '\n')
40}
41
42function promptUser (prompt) {
43 const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
44 return new Promise(resolve => rl.question(prompt, answer => {
45 rl.close()
46 resolve(answer)
47 }))
48}
49
50async function main () {
51 const args = process.argv.slice(2)
52 const candidates = readCandidates()
53
54 if (candidates.length === 0) {
55 console.log('No pending rule candidates.')
56 return
57 }
58
59 if (args.includes('--list')) {
60 console.log(`${candidates.length} pending candidates:\n`)
61 for (const c of candidates) {
62 console.log(` [${c.timestamp.slice(0, 19)}]`)
63 console.log(` symptom: ${c.symptom.slice(0, 120)}`)
64 console.log(` files: ${c.triggerFiles.slice(0, 3).join(', ')}`)
65 console.log(` suggested: ${c.suggestedRule}`)
66 console.log()
67 }
68 return
69 }
70
71 if (args.includes('--clear')) {
72 console.log(`Rejecting all ${candidates.length} pending candidates...`)
73 for (const c of candidates) {
74 archive({ ...c, status: 'rejected-bulk', reviewedAt: new Date().toISOString() })
75 }
76 writeCandidates([])
77 console.log('Done.')
78 return
79 }
80
81 console.log(`Reviewing ${candidates.length} pending rule candidates.`)
82 console.log('Commands: [a]ccept [r]eject [e]dit [s]kip [q]uit\n')
83
84 const remaining = []
85 for (let i = 0; i < candidates.length; i++) {
86 const c = candidates[i]
87 console.log(`─── Candidate ${i + 1} of ${candidates.length} ───`)
88 console.log(`Timestamp: ${c.timestamp}`)
89 console.log(`Symptom: ${c.symptom}`)
90 console.log(`Files: ${c.triggerFiles.join(', ')}`)
91 console.log(`Suggested: ${c.suggestedRule}`)
92 console.log()
93
94 const choice = (await promptUser('Action [a/r/e/s/q]: ')).trim().toLowerCase()
95
96 if (choice === 'q') {
97 remaining.push(...candidates.slice(i))
98 break
99 } else if (choice === 'a') {
100 archive({ ...c, status: 'accepted', reviewedAt: new Date().toISOString() })
101 // Append to learned-rules.json
102 try {
103 const rules = fs.existsSync(LEARNED_RULES_FILE)
104 ? JSON.parse(fs.readFileSync(LEARNED_RULES_FILE, 'utf8'))
105 : { rules: [] }
106 const list = Array.isArray(rules) ? rules : (rules.rules || [])
107 list.push({
108 id: `rule-user-${Date.now().toString(36)}`,
109 source: 'user-accepted-candidate',
110 trigger: { files: c.triggerFiles, symptom: c.symptom },
111 action: { description: c.suggestedRule },
112 quality: 'specific',
113 reviewedAt: new Date().toISOString()
114 })
115 if (Array.isArray(rules)) fs.writeFileSync(LEARNED_RULES_FILE, JSON.stringify(list, null, 2))
116 else { rules.rules = list; rules.totalRules = list.length; fs.writeFileSync(LEARNED_RULES_FILE, JSON.stringify(rules, null, 2)) }
117 console.log('✓ Accepted and added to learned-rules.json\n')
118 } catch (e) {
119 console.log('✗ Failed to persist: ' + e.message + '\n')
120 }
121 } else if (choice === 'r') {
122 archive({ ...c, status: 'rejected', reviewedAt: new Date().toISOString() })
123 console.log('✗ Rejected\n')
124 } else if (choice === 'e') {
125 const newRule = await promptUser('Refined rule: ')
126 if (newRule.trim()) {
127 archive({ ...c, suggestedRule: newRule, status: 'accepted-edited', reviewedAt: new Date().toISOString() })
128 console.log('✎ Accepted with edits\n')
129 } else {
130 remaining.push(c)
131 console.log('(skipped — no edit entered)\n')
132 }
133 } else {
134 remaining.push(c) // skip/unknown -> stays in queue
135 console.log('(skipped)\n')
136 }
137 }
138
139 writeCandidates(remaining)
140 console.log(`Review complete. ${remaining.length} candidates remain in queue.`)
141}
142
143if (require.main === module) {
144 main().catch(err => { console.error(err.message); process.exit(1) })
145}
atomspace/schema.js +138 -0
……@@ -0,0 +1,138 @@
1/**
2 * schema.js — Atom naming conventions and Scheme expression builders
3 *
4 * All atoms use prefixed ConceptNode names:
5 * mod: — modules (package.json boundaries)
6 * file: — source files (relative paths)
7 * fn: — exported functions/classes
8 * loop: — correction loops (by fingerprint)
9 * rule: — learned rules (by id)
10 *
11 * All edges use EdgeLink + BondNode (learn-pipeline compatible):
12 * (EdgeLink (BondNode "TYPE") (ListLink SRC DST))
13 *
14 * Bond types:
15 * contains — module contains file
16 * imports — file imports from file
17 * depends-on — module depends on module
18 * exports — file exports function/class
19 * loop-in-file — loop involved this file
20 * resolved-by — loop was resolved by editing this file
21 * rule-for-file — learned rule applies to this file
22 */
23
24// Escape strings for Scheme (double quotes, backslashes)
25function esc (str) {
26 if (!str) return ''
27 return str
28 .replace(/\\/g, '\\\\')
29 .replace(/"/g, '\\"')
30 .replace(/\n/g, '\\n')
31}
32
33// --- Node builders ---
34
35function modNode (name) {
36 return `(ConceptNode "mod:${esc(name)}")`
37}
38
39function fileNode (path) {
40 return `(ConceptNode "file:${esc(path)}")`
41}
42
43function fnNode (name) {
44 return `(ConceptNode "fn:${esc(name)}")`
45}
46
47function loopNode (fingerprint) {
48 return `(ConceptNode "loop:${esc(fingerprint)}")`
49}
50
51function ruleNode (id) {
52 return `(ConceptNode "rule:${esc(id)}")`
53}
54
55// --- Edge builder ---
56
57function edge (bondType, src, dst) {
58 return `(EdgeLink (BondNode "${esc(bondType)}") (ListLink ${src} ${dst}))`
59}
60
61// --- Value setters ---
62
63function setStringValue (node, key, value) {
64 return `(cog-set-value! ${node} (Predicate "${esc(key)}") (StringValue "${esc(value)}"))`
65}
66
67function setFloatValue (node, key, value) {
68 return `(cog-set-value! ${node} (Predicate "${esc(key)}") (FloatValue ${value}))`
69}
70
71// --- Query builders ---
72
73function meetQuery (varName, varType, body) {
74 return `(cog-execute! (Meet (TypedVariable (Variable "${varName}") (Type '${varType})) (Present ${body})))`
75}
76
77// Find all files that import from a given file
78function queryImporters (filePath) {
79 const target = fileNode(filePath)
80 return meetQuery('$consumer', 'ConceptNode',
81 `(EdgeLink (BondNode "imports") (ListLink (Variable "$consumer") ${target}))`)
82}
83
84// Find all files that a given file imports from
85function queryDependencies (filePath) {
86 const source = fileNode(filePath)
87 return meetQuery('$dep', 'ConceptNode',
88 `(EdgeLink (BondNode "imports") (ListLink ${source} (Variable "$dep")))`)
89}
90
91// Find all loops involving a file
92function queryLoopsForFile (filePath) {
93 const target = fileNode(filePath)
94 return meetQuery('$loop', 'ConceptNode',
95 `(EdgeLink (BondNode "loop-in-file") (ListLink (Variable "$loop") ${target}))`)
96}
97
98// Find all rules applying to a file
99function queryRulesForFile (filePath) {
100 const target = fileNode(filePath)
101 return meetQuery('$rule', 'ConceptNode',
102 `(EdgeLink (BondNode "rule-for-file") (ListLink (Variable "$rule") ${target}))`)
103}
104
105// Find all files in a module
106function queryModuleFiles (modName) {
107 const mod = modNode(modName)
108 return meetQuery('$file', 'ConceptNode',
109 `(EdgeLink (BondNode "contains") (ListLink ${mod} (Variable "$file")))`)
110}
111
112// Get a string value from an atom
113function getStringValue (node, key) {
114 return `(let ((v (cog-value ${node} (Predicate "${esc(key)}")))) (if v (cog-value-ref v 0) ""))`
115}
116
117// Get a float value from an atom
118function getFloatValue (node, key) {
119 return `(let ((v (cog-value ${node} (Predicate "${esc(key)}")))) (if v (cog-value-ref v 0) 0))`
120}
121
122// Count atoms in AtomSpace
123function reportCounts () {
124 return '(cog-report-counts)'
125}
126
127module.exports = {
128 esc,
129 modNode, fileNode, fnNode, loopNode, ruleNode,
130 edge,
131 setStringValue, setFloatValue,
132 meetQuery,
133 queryImporters, queryDependencies,
134 queryLoopsForFile, queryRulesForFile,
135 queryModuleFiles,
136 getStringValue, getFloatValue,
137 reportCounts
138}
atomspace/semantic-embed.js +227 -0
……@@ -0,0 +1,227 @@
1#!/usr/bin/env node
2
3/**
4 * semantic-embed.js — Simple text embedding for semantic repeat detection
5 *
6 * Current R2 uses Jaccard on word sets with threshold 0.4.
7 * Misses semantic repeats with different vocabulary:
8 * "stop that's wrong" vs "no, that's incorrect" vs "wait, that's not right"
9 *
10 * This module adds:
11 * 1. Character n-gram fingerprints (catches morphological variation)
12 * 2. Conceptual grouping by correction INTENT (regex-classified)
13 * 3. Semantic signature = (intent-class, content-fingerprint)
14 *
15 * Two corrections are semantic-repeats if they share intent-class AND
16 * have high content similarity.
17 */
18
19// Intent classes — regex-based "what is this correction saying"
20const INTENT_PATTERNS = [
21 { name: 'stop-wrong-direction', patterns: [
22 /\b(stop|wait|halt)\b.*\b(wrong|incorrect|not right|that's not)\b/i,
23 /\b(no|not that)\b.*\b(direction|approach|way)\b/i,
24 /\bgoing (in the )?wrong\b/i
25 ]},
26 { name: 'use-existing', patterns: [
27 /\b(use|try) .+ instead\b/i,
28 /\bwe (already )?have\b/i,
29 /\b(there is|there's) (already )?(a|an) \w+/i,
30 /\balready (exists?|built|done)\b/i
31 ]},
32 { name: 'read-first', patterns: [
33 /\bread (the|this) (file|docs?|code|source)\b/i,
34 /\b(did|have) you (even )?(read|look at)\b/i,
35 /\bRTFM\b/i,
36 /\bcheck the (docs?|source|file)\b/i
37 ]},
38 { name: 'verify-first', patterns: [
39 /\bverify\b/i,
40 /\bdid you (test|check|confirm|verify)\b/i,
41 /\bdouble[- ]?check\b/i,
42 /\bconfirm\b/i
43 ]},
44 { name: 'explain-why', patterns: [
45 /\bwhy\b.*\?/i,
46 /\bexplain\b/i,
47 /\bwhat are you (trying to )?do(ing)?\b/i
48 ]},
49 { name: 'too-much-too-fast', patterns: [
50 /\bslow down\b/i,
51 /\btoo (fast|much)\b/i,
52 /\bone (thing|step) at a time\b/i,
53 /\bwait\b/i
54 ]},
55 { name: 'wrong-file', patterns: [
56 /\bwrong file\b/i,
57 /\b(not|that's not) the (file|one)\b/i,
58 /\bin the other file\b/i
59 ]},
60 { name: 'frustration', patterns: [
61 /\b(again|how many times|already told you|we discussed)\b/i,
62 /\b(still|keep)\b.*\b(wrong|broken|not working)\b/i
63 ]},
64 { name: 'forgot-context', patterns: [
65 /\b(you forgot|forgetting|remember)\b/i,
66 /\b(the plan|the task|what we're doing)\b/i
67 ]},
68 { name: 'destructive-warning', patterns: [
69 /\bwhat are you doing\b/i,
70 /\bchanging production\b/i,
71 /\bdon't (delete|remove|rm)\b/i
72 ]}
73]
74
75/**
76 * Classify a correction into one or more intent classes.
77 * Returns array of matching intent names (may be empty).
78 */
79function classifyIntent (text) {
80 if (!text) return []
81 const matched = []
82 for (const { name, patterns } of INTENT_PATTERNS) {
83 if (patterns.some(p => p.test(text))) matched.push(name)
84 }
85 return matched
86}
87
88/**
89 * Character n-gram fingerprint — a Set of character trigrams from text.
90 * Catches morphological variation (stemming handled via overlap).
91 */
92function ngramFingerprint (text, n = 3) {
93 if (!text) return new Set()
94 const cleaned = text.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim()
95 const grams = new Set()
96 for (let i = 0; i <= cleaned.length - n; i++) {
97 grams.add(cleaned.slice(i, i + n))
98 }
99 return grams
100}
101
102/**
103 * Cosine-like similarity between two ngram sets.
104 * |intersection| / sqrt(|a|*|b|)
105 */
106function ngramSimilarity (a, b) {
107 if (a.size === 0 || b.size === 0) return 0
108 let intersection = 0
109 for (const g of a) if (b.has(g)) intersection++
110 return intersection / Math.sqrt(a.size * b.size)
111}
112
113/**
114 * Semantic repeat detection — two corrections are repeats if:
115 * - They share at least 1 intent class, AND
116 * - ngram similarity > 0.3 (looser than word Jaccard, since n-grams are granular)
117 * OR
118 * - ngram similarity > 0.5 regardless of intent
119 */
120function isSemanticRepeat (a, b) {
121 const intentA = classifyIntent(a)
122 const intentB = classifyIntent(b)
123
124 const fpA = ngramFingerprint(a)
125 const fpB = ngramFingerprint(b)
126 const sim = ngramSimilarity(fpA, fpB)
127
128 // Strong content overlap = repeat
129 if (sim > 0.5) return true
130
131 // Shared intent + medium content overlap = repeat
132 const sharedIntent = intentA.some(i => intentB.includes(i))
133 if (sharedIntent && sim > 0.3) return true
134
135 return false
136}
137
138/**
139 * Compute novel vs repeat over a stream of corrections.
140 * Returns: { novel, repeat, novelRate, byIntent, improvedNovelRate }
141 *
142 * Also returns the "improved" delta — how many corrections are now classified
143 * as repeats that the simple Jaccard missed.
144 */
145function analyzeCorrections (corrections) {
146 let novel = 0, repeat = 0
147 const seen = [] // array of { text, intents, fingerprint }
148 const byIntent = {}
149
150 for (const c of corrections) {
151 const text = c.text || c
152 const intents = classifyIntent(text)
153 const fp = ngramFingerprint(text)
154
155 // Check against all prior
156 let isRepeat = false
157 for (const past of seen) {
158 const sim = ngramSimilarity(fp, past.fingerprint)
159 if (sim > 0.5) { isRepeat = true; break }
160 const sharedIntent = intents.some(i => past.intents.includes(i))
161 if (sharedIntent && sim > 0.3) { isRepeat = true; break }
162 }
163
164 if (isRepeat) repeat++
165 else novel++
166
167 // Track by primary intent
168 const primary = intents[0] || 'uncategorized'
169 if (!byIntent[primary]) byIntent[primary] = { novel: 0, repeat: 0 }
170 if (isRepeat) byIntent[primary].repeat++
171 else byIntent[primary].novel++
172
173 seen.push({ text, intents, fingerprint: fp })
174 }
175
176 const total = novel + repeat
177 return {
178 novel,
179 repeat,
180 total,
181 novelRate: total > 0 ? novel / total : null,
182 byIntent
183 }
184}
185
186// CLI — run against corrections.log
187if (require.main === module) {
188 const fs = require('fs')
189 const path = require('path')
190
191 const LOG = path.join(__dirname, '..', 'corrections.log')
192 if (!fs.existsSync(LOG)) {
193 console.log('No corrections log')
194 process.exit(0)
195 }
196
197 const content = fs.readFileSync(LOG, 'utf8')
198 const blocks = content.split('\n---\n').filter(Boolean)
199 const corrections = []
200 for (const block of blocks) {
201 const lines = block.trim().split('\n')
202 if (lines.length < 2) continue
203 const m = lines[0].match(/^(\d{4}-\d{2}-\d{2}T[\d:.]+Z)\s*\[correction\]/)
204 if (!m) continue
205 const text = lines.slice(1).filter(l => !l.startsWith('cwd:')).join('\n').trim()
206 if (text) corrections.push({ text })
207 }
208
209 console.log(`Analyzing ${corrections.length} corrections with semantic repeat detection...`)
210 const result = analyzeCorrections(corrections)
211
212 console.log('')
213 console.log('== SEMANTIC NOVEL-RATE ==')
214 console.log(`Total corrections: ${result.total}`)
215 console.log(`Novel: ${result.novel} (${(result.novelRate * 100).toFixed(1)}%)`)
216 console.log(`Repeat: ${result.repeat} (${((1 - result.novelRate) * 100).toFixed(1)}%)`)
217
218 console.log('\n== By Intent (top 10) ==')
219 const sorted = Object.entries(result.byIntent).sort((a, b) => (b[1].novel + b[1].repeat) - (a[1].novel + a[1].repeat))
220 for (const [intent, s] of sorted.slice(0, 10)) {
221 const total = s.novel + s.repeat
222 const novelPct = total > 0 ? (s.novel / total * 100).toFixed(0) : '-'
223 console.log(` ${intent.padEnd(25)} ${total} corrections (${s.novel} novel, ${s.repeat} repeat, ${novelPct}% novel)`)
224 }
225}
226
227module.exports = { classifyIntent, ngramFingerprint, ngramSimilarity, isSemanticRepeat, analyzeCorrections }
atomspace/session-categorize.js +368 -0
……@@ -0,0 +1,368 @@
1#!/usr/bin/env node
2
3/**
4 * session-categorize.js — Tag sessions with topics, link continuation chains
5 *
6 * For each session in fused-scores.json:
7 * 1. Infer topics from git branch, dominant files edited, correction themes
8 * 2. Create session atom in AtomSpace with rich metadata
9 * 3. Link to topic atoms via 'session-about' bond
10 * 4. Detect continuation chains (same branch/cwd, close in time, shared files)
11 *
12 * Topics become queryable long-term memory:
13 * "What sessions contributed to atomspace integration?" → chain traversal
14 * "Last time I worked on pear-git-server, what was learned?" → session query
15 *
16 * Usage:
17 * node atomspace/session-categorize.js # run and ingest
18 * node atomspace/session-categorize.js --dry-run # print, don't store
19 */
20
21const fs = require('fs')
22const path = require('path')
23const { isCogServerUp, evalScheme } = require('./cogserver')
24
25const DATA_DIR = path.join(__dirname, '..', 'data')
26const FUSED_FILE = path.join(DATA_DIR, 'fused-scores.json')
27const LOOPS_FILE = path.join(DATA_DIR, 'loops.json')
28
29function esc (s) {
30 return String(s || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"')
31}
32
33/**
34 * Extract the dominant module from a set of file paths.
35 * Returns the module (first path segment) most frequently touched.
36 */
37function dominantModule (files) {
38 if (!files || files.length === 0) return null
39 const counts = {}
40 for (const f of files) {
41 if (!f) continue
42 const normalized = f
43 .replace(/^\/storage\/projects\/code\/hypercore-kb\/hyper-fleeky\//, '')
44 .replace(/^\/storage\/projects\/code\/hypercore-kb\//, '')
45 .replace(/^\/storage\/projects\/code\//, '')
46 const mod = normalized.split('/')[0]
47 if (mod && !mod.startsWith('.') && !mod.startsWith('/home')) {
48 counts[mod] = (counts[mod] || 0) + 1
49 }
50 }
51 const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1])
52 return sorted.length > 0 ? sorted[0][0] : null
53}
54
55/**
56 * Infer topics from a session's metadata.
57 *
58 * Signals (in priority order):
59 * 1. FILE-EDIT-CLUSTER — dominant module actually edited (most reliable)
60 * 2. Loop file clusters — where did loops happen?
61 * 3. CWD project — the intended project (can differ from actual work)
62 * 4. Git branch — often misleading (drifts), used as fallback
63 * 5. Loop types — struggle patterns
64 */
65function inferTopics (session, loops, sessionTurnDetail) {
66 const topics = new Set()
67
68 // 1. FILE-EDIT-CLUSTER — the most reliable topic signal
69 // Collect all files edited in this session
70 const sessionLoops = (loops.loops || []).filter(l => l.sessionId === session.sessionId)
71 const loopFiles = sessionLoops.flatMap(l => [...(l.filesTried || []), ...((l.resolution || {}).files || [])])
72 const turnEdits = sessionTurnDetail ? (sessionTurnDetail.turns || []).flatMap(t => t.editFiles || []) : []
73 const allFiles = [...loopFiles, ...turnEdits]
74
75 const domMod = dominantModule(allFiles)
76 if (domMod) {
77 topics.add(`module:${domMod}`)
78 }
79
80 // 2. All modules touched (not just dominant) — gives richer topic set
81 const moduleCounts = {}
82 for (const f of allFiles) {
83 if (!f) continue
84 const normalized = f
85 .replace(/^\/storage\/projects\/code\/hypercore-kb\/hyper-fleeky\//, '')
86 .replace(/^\/storage\/projects\/code\/hypercore-kb\//, '')
87 .replace(/^\/storage\/projects\/code\//, '')
88 const mod = normalized.split('/')[0]
89 if (mod && !mod.startsWith('.') && !mod.startsWith('/home')) {
90 moduleCounts[mod] = (moduleCounts[mod] || 0) + 1
91 }
92 }
93 // Add top-3 touched modules (captures multi-module sessions)
94 const topModules = Object.entries(moduleCounts).sort((a, b) => b[1] - a[1]).slice(0, 3)
95 for (const [mod, count] of topModules) {
96 if (count >= 2) topics.add(`module:${mod}`)
97 }
98
99 // 3. Project from cwd (fallback if no files edited)
100 if (session.cwd) {
101 const m = session.cwd.match(/\/hyper-fleeky\/([^/]+)/) ||
102 session.cwd.match(/\/godot\/([^/]+)/) ||
103 session.cwd.match(/\/hypercore-kb\/([^/]+)/)
104 if (m) topics.add(`project:${m[1]}`)
105 }
106
107 // 4. Git branch (LOW priority — known to drift)
108 // Only add if branch looks specific (not a long-lived branch)
109 if (session.gitBranch &&
110 session.gitBranch !== 'main' && session.gitBranch !== 'master' &&
111 session.gitBranch !== 'hypernote-ui-improvements') {
112 const branch = session.gitBranch
113 .replace(/^(feature|fix|wip|refactor|chore|feat)\//, '')
114 if (branch.length > 2 && branch.length < 40) topics.add(`branch:${branch}`)
115 }
116
117 // 5. Intent categorization
118 const branch = (session.gitBranch || '').toLowerCase()
119 if (/fix|bug|error/.test(branch)) topics.add('intent:bugfix')
120 else if (/feat|feature|add|new/.test(branch)) topics.add('intent:feature')
121 else if (/refactor|cleanup|rewrite/.test(branch)) topics.add('intent:refactor')
122
123 // 6. Struggle themes from loops
124 const typeCounts = {}
125 for (const l of sessionLoops) {
126 typeCounts[l.type] = (typeCounts[l.type] || 0) + 1
127 }
128 const domType = Object.entries(typeCounts).sort((a, b) => b[1] - a[1])[0]
129 if (domType && domType[1] >= 2) {
130 topics.add(`struggle:${domType[0]}`)
131 }
132
133 return [...topics]
134}
135
136/**
137 * Determine session territory: new vs established.
138 * Based on how many prior sessions touched the same dominant module.
139 *
140 * new — <3 prior sessions on this module (exploring unknown territory)
141 * familiar — 3-10 prior sessions
142 * established — >10 prior sessions (well-known territory)
143 */
144function classifyTerritory (session, allSessions, sessionTopics) {
145 // Get this session's dominant module from its topics
146 const modTopics = (sessionTopics || []).filter(t => t.startsWith('module:'))
147 if (modTopics.length === 0) return 'unknown'
148
149 const domModule = modTopics[0].replace('module:', '')
150 const sessionTs = new Date(session.timestamp || session.date).getTime()
151
152 // Count prior sessions that touched this module
153 let priorCount = 0
154 for (const s of allSessions) {
155 if (s.sessionId === session.sessionId) continue
156 const sTs = new Date(s.timestamp || s.date).getTime()
157 if (sTs >= sessionTs) continue // only earlier sessions
158 if (s.cwd && s.cwd.includes('/' + domModule)) priorCount++
159 // Also count same branch as weak signal
160 if (s.gitBranch && s.gitBranch === session.gitBranch) priorCount += 0.3
161 }
162
163 if (priorCount < 3) return 'new'
164 if (priorCount < 10) return 'familiar'
165 return 'established'
166}
167
168/**
169 * Determine session outcome.
170 * - completed: resolved loops > unresolved
171 * - stuck: more unresolved than resolved
172 * - clean: few/no loops, likely productive
173 * - aborted: short session, lots of tool calls (possibly abandoned)
174 */
175function inferOutcome (session, loops) {
176 const sessionLoops = (loops.loops || []).filter(l => l.sessionId === session.sessionId)
177 const resolved = sessionLoops.filter(l => l.resolved).length
178 const unresolved = sessionLoops.length - resolved
179
180 if (sessionLoops.length === 0) return 'clean'
181 if (unresolved > resolved) return 'stuck'
182 if (resolved > 0 && unresolved === 0) return 'completed'
183 return 'mixed'
184}
185
186/**
187 * Detect continuation: previous session close in time, same branch + cwd.
188 */
189function findContinuationOf (session, allSessions) {
190 const sessionTs = new Date(session.timestamp || session.date).getTime()
191 const candidates = allSessions.filter(s =>
192 s.sessionId !== session.sessionId &&
193 s.gitBranch === session.gitBranch &&
194 s.cwd === session.cwd &&
195 new Date(s.timestamp || s.date).getTime() < sessionTs
196 )
197 if (candidates.length === 0) return null
198
199 // Most recent prior session
200 candidates.sort((a, b) => new Date(b.timestamp || b.date).getTime() - new Date(a.timestamp || a.date).getTime())
201 const prev = candidates[0]
202 const gapHours = (sessionTs - new Date(prev.timestamp || prev.date).getTime()) / 1000 / 3600
203
204 // Continuation if same branch+cwd and within 7 days
205 if (gapHours <= 24 * 7) return { sessionId: prev.sessionId, gapHours: Math.round(gapHours * 10) / 10 }
206 return null
207}
208
209/**
210 * Build Atomese expressions for a session.
211 */
212function sessionToScheme (session, topics, outcome, continuationOf, territory) {
213 const exprs = []
214 const sid = session.sessionId
215 const sessionNode = `(ConceptNode "session-archive:${esc(sid)}")`
216
217 exprs.push(sessionNode)
218 exprs.push(`(cog-set-value! ${sessionNode} (Predicate "date") (StringValue "${esc(session.date)}"))`)
219 exprs.push(`(cog-set-value! ${sessionNode} (Predicate "turns") (FloatValue ${session.turns || 0}))`)
220 exprs.push(`(cog-set-value! ${sessionNode} (Predicate "score") (FloatValue ${session.score || 0}))`)
221 if (session.perTurnScore != null) {
222 exprs.push(`(cog-set-value! ${sessionNode} (Predicate "per-turn-score") (FloatValue ${session.perTurnScore}))`)
223 }
224 if (session.trustedScore != null) {
225 exprs.push(`(cog-set-value! ${sessionNode} (Predicate "trusted-score") (FloatValue ${session.trustedScore}))`)
226 }
227 exprs.push(`(cog-set-value! ${sessionNode} (Predicate "corrections") (FloatValue ${session.corrections || 0}))`)
228 exprs.push(`(cog-set-value! ${sessionNode} (Predicate "approvals") (FloatValue ${session.approvals || 0}))`)
229 exprs.push(`(cog-set-value! ${sessionNode} (Predicate "outcome") (StringValue "${outcome}"))`)
230 exprs.push(`(cog-set-value! ${sessionNode} (Predicate "territory") (StringValue "${territory || 'unknown'}"))`)
231 if (session.gitBranch) {
232 exprs.push(`(cog-set-value! ${sessionNode} (Predicate "git-branch") (StringValue "${esc(session.gitBranch)}"))`)
233 }
234
235 // Topic edges
236 for (const topic of topics) {
237 const topicNode = `(ConceptNode "topic:${esc(topic)}")`
238 exprs.push(topicNode)
239 exprs.push(`(EdgeLink (BondNode "session-about") (ListLink ${sessionNode} ${topicNode}))`)
240 }
241
242 // Continuation edge
243 if (continuationOf) {
244 const prevNode = `(ConceptNode "session-archive:${esc(continuationOf.sessionId)}")`
245 exprs.push(`(EdgeLink (BondNode "continues") (ListLink ${sessionNode} ${prevNode}))`)
246 exprs.push(`(cog-set-value! ${sessionNode} (Predicate "continues-after-hours") (FloatValue ${continuationOf.gapHours}))`)
247 }
248
249 return exprs
250}
251
252async function main () {
253 const dryRun = process.argv.includes('--dry-run')
254
255 const fused = JSON.parse(fs.readFileSync(FUSED_FILE, 'utf8'))
256 const loops = JSON.parse(fs.readFileSync(LOOPS_FILE, 'utf8'))
257 const sessions = fused.sessions || []
258
259 // Load turn-level detail for file-edit-cluster topic extraction
260 const TURNS_FILE = path.join(DATA_DIR, 'fused-turns-detail.json')
261 let turnDetailBySession = {}
262 try {
263 const detail = JSON.parse(fs.readFileSync(TURNS_FILE, 'utf8'))
264 const detailSessions = detail.sessions || detail
265 for (const s of detailSessions) turnDetailBySession[s.sessionId] = s
266 } catch {}
267
268 console.log(`Categorizing ${sessions.length} sessions...`)
269
270 const categorized = sessions.map(s => {
271 const detail = turnDetailBySession[s.sessionId]
272 const topics = inferTopics(s, loops, detail)
273 const outcome = inferOutcome(s, loops)
274 const continuationOf = findContinuationOf(s, sessions)
275 const territory = classifyTerritory(s, sessions, topics)
276 return { session: s, topics, outcome, continuationOf, territory }
277 })
278
279 // Topic frequency
280 const topicCounts = {}
281 for (const c of categorized) {
282 for (const t of c.topics) {
283 topicCounts[t] = (topicCounts[t] || 0) + 1
284 }
285 }
286
287 // Outcome distribution
288 const outcomes = {}
289 for (const c of categorized) {
290 outcomes[c.outcome] = (outcomes[c.outcome] || 0) + 1
291 }
292
293 // Continuation stats
294 const continuations = categorized.filter(c => c.continuationOf).length
295
296 console.log('')
297 console.log('── Topic distribution (top 15) ──')
298 const topTopics = Object.entries(topicCounts).sort((a, b) => b[1] - a[1]).slice(0, 15)
299 for (const [t, n] of topTopics) {
300 console.log(` ${String(n).padStart(4)} sessions ${t}`)
301 }
302
303 console.log('')
304 console.log('── Outcome distribution ──')
305 for (const [o, n] of Object.entries(outcomes).sort((a, b) => b[1] - a[1])) {
306 console.log(` ${String(n).padStart(4)} sessions ${o}`)
307 }
308
309 // Territory distribution — new vs established
310 const territoryCounts = {}
311 const territoryScores = {}
312 for (const c of categorized) {
313 const t = c.territory || 'unknown'
314 territoryCounts[t] = (territoryCounts[t] || 0) + 1
315 if (!territoryScores[t]) territoryScores[t] = []
316 if (c.session.trustedScore != null) territoryScores[t].push(c.session.trustedScore)
317 }
318 console.log('')
319 console.log('── Territory (where work happened) ──')
320 for (const [t, n] of Object.entries(territoryCounts).sort((a, b) => b[1] - a[1])) {
321 const scores = territoryScores[t] || []
322 const avgScore = scores.length > 0
323 ? (scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(3)
324 : 'n/a'
325 console.log(` ${String(n).padStart(4)} sessions ${t.padEnd(14)} avg trusted-score=${avgScore}`)
326 }
327
328 console.log('')
329 console.log(`── Continuation chains: ${continuations}/${sessions.length} sessions continue a previous one ──`)
330 for (const c of categorized.filter(c => c.continuationOf).slice(-5)) {
331 console.log(` ${c.session.date} (${c.session.sessionId.slice(0, 8)}…) ← after ${c.continuationOf.gapHours}h gap`)
332 }
333
334 if (dryRun) {
335 console.log('\n(--dry-run: not storing)')
336 return { categorized: categorized.length }
337 }
338
339 if (!await isCogServerUp()) {
340 console.log('\nCogServer not running, skipping ingest')
341 return { ok: false }
342 }
343
344 // Build and ingest Scheme expressions
345 const allExprs = []
346 for (const c of categorized) {
347 allExprs.push(...sessionToScheme(c.session, c.topics, c.outcome, c.continuationOf, c.territory))
348 }
349
350 console.log(`\nIngesting ${allExprs.length} Scheme expressions...`)
351 const BATCH = 50
352 let sent = 0
353 for (let i = 0; i < allExprs.length; i += BATCH) {
354 const batch = allExprs.slice(i, i + BATCH)
355 await evalScheme('(begin ' + batch.join(' ') + ')')
356 sent += batch.length
357 }
358
359 console.log(`Stored: ${sent} atoms/edges for ${categorized.length} sessions`)
360 return { ok: true, sessions: categorized.length, sent }
361}
362
363if (require.main === module) {
364 main().then(r => console.log('\n' + JSON.stringify(r || {}, null, 2)))
365 .catch(err => { console.error(err.message); process.exit(1) })
366}
367
368module.exports = { inferTopics, inferOutcome, findContinuationOf }
atomspace/session-start.js +186 -0
……@@ -0,0 +1,186 @@
1#!/usr/bin/env node
2
3/**
4 * session-start.js — Start CogServer and ingest data at session begin
5 *
6 * Called from SessionStart hook. Async — does not block session.
7 * Starts CogServer if not running, then ingests codetree + ratchet data.
8 * Fails silently if CogServer binary not found.
9 */
10
11const { execSync, spawn } = require('child_process')
12const { isCogServerUp, evalScheme } = require('./cogserver')
13const { ingestAll } = require('./ingest')
14
15const COGSERVER_PORT = 17001
16const STARTUP_WAIT = 2000
17
18async function startCogServerIfNeeded () {
19 if (await isCogServerUp(COGSERVER_PORT)) {
20 return true
21 }
22
23 // Check if cogserver binary exists
24 try {
25 execSync('which cogserver', { encoding: 'utf8', stdio: 'pipe' })
26 } catch {
27 return false // no cogserver binary
28 }
29
30 // Start CogServer in background
31 try {
32 const child = spawn('cogserver', [], {
33 detached: true,
34 stdio: 'ignore'
35 })
36 child.unref()
37
38 // Wait for it to come up
39 const deadline = Date.now() + STARTUP_WAIT
40 while (Date.now() < deadline) {
41 if (await isCogServerUp(COGSERVER_PORT)) return true
42 await new Promise(r => setTimeout(r, 200))
43 }
44 return await isCogServerUp(COGSERVER_PORT)
45 } catch {
46 return false
47 }
48}
49
50/**
51 * Create a session atom tagged with a UUID so streaming hooks can link to it.
52 * Writes the session UUID to /tmp/claude-atomspace-session so hooks can read it.
53 */
54async function createSessionAtom () {
55 const crypto = require('crypto')
56 const fs = require('fs')
57 const sessionId = crypto.randomBytes(8).toString('hex')
58 const startTime = Math.floor(Date.now() / 1000)
59 const cwd = process.cwd()
60
61 const sessionNode = `(ConceptNode "session:${sessionId}")`
62 const exprs = [
63 sessionNode,
64 `(cog-set-value! ${sessionNode} (Predicate "start-time") (FloatValue ${startTime}))`,
65 `(cog-set-value! ${sessionNode} (Predicate "cwd") (StringValue "${cwd.replace(/"/g, '\\"')}"))`,
66 `(cog-set-value! ${sessionNode} (Predicate "turn") (FloatValue 0))`,
67 `(cog-set-value! ${sessionNode} (Predicate "correction-count") (FloatValue 0))`
68 ]
69
70 await evalScheme('(begin ' + exprs.join(' ') + ')')
71
72 // Write session ID to a known path so hooks can find it
73 fs.writeFileSync('/tmp/claude-atomspace-session', sessionId)
74 return sessionId
75}
76
77async function main () {
78 const started = await startCogServerIfNeeded()
79 if (!started) {
80 // CogServer not available — silent exit
81 process.exit(0)
82 }
83
84 // Run ingest
85 try {
86 const result = await ingestAll()
87 if (result.ok) {
88 const atoms = (result.codetree ? result.codetree.sent || 0 : 0) +
89 (result.ratchet ? result.ratchet.sent || 0 : 0)
90 process.stderr.write(`[AtomSpace] Loaded ${atoms} atoms (${result.elapsed}ms)\n`)
91 }
92 } catch (err) {
93 process.stderr.write(`[AtomSpace] Ingest error: ${err.message}\n`)
94 }
95
96 // Create session atom for streaming observability
97 try {
98 const sessionId = await createSessionAtom()
99 process.stderr.write(`[AtomSpace] Session: ${sessionId}\n`)
100 } catch (err) {
101 process.stderr.write(`[AtomSpace] Session init error: ${err.message}\n`)
102 }
103
104 // Phase 3.2: Continuation pre-loading
105 // Detect if this session continues a prior one and inject context
106 try {
107 await preloadContinuationContext()
108 } catch (err) {
109 // Non-fatal
110 }
111
112 process.exit(0)
113}
114
115/**
116 * If recent sessions (within 7 days) share the current cwd, surface
117 * context from them at session start: unresolved loops, dominant topics,
118 * last session's outcome.
119 */
120async function preloadContinuationContext () {
121 const fs = require('fs')
122 const path = require('path')
123 const cwd = process.cwd()
124
125 // Read recent session data from fused-scores
126 const fusedFile = path.join(__dirname, '..', 'data', 'fused-scores.json')
127 if (!fs.existsSync(fusedFile)) return
128 const fused = JSON.parse(fs.readFileSync(fusedFile, 'utf8'))
129
130 // Find recent sessions in same cwd within 7 days
131 const now = Date.now()
132 const sevenDaysMs = 7 * 24 * 3600 * 1000
133 const recent = (fused.sessions || []).filter(s => {
134 if (s.cwd !== cwd) return false
135 const ts = new Date(s.timestamp || s.date).getTime()
136 return (now - ts) <= sevenDaysMs
137 }).sort((a, b) =>
138 new Date(b.timestamp || b.date).getTime() - new Date(a.timestamp || a.date).getTime()
139 )
140
141 if (recent.length === 0) return
142
143 // Pull loops from same cwd
144 const loopsFile = path.join(__dirname, '..', 'data', 'loops.json')
145 let unresolvedLoops = []
146 if (fs.existsSync(loopsFile)) {
147 const loops = JSON.parse(fs.readFileSync(loopsFile, 'utf8'))
148 const priorSessionIds = new Set(recent.map(s => s.sessionId))
149 unresolvedLoops = (loops.loops || []).filter(l =>
150 priorSessionIds.has(l.sessionId) && !l.resolved
151 )
152 }
153
154 // Build briefing
155 const lines = []
156 lines.push('─── PRIOR CONTEXT (last 7 days, same cwd) ───')
157 lines.push(`${recent.length} prior session(s) on this cwd`)
158
159 // Most recent session summary
160 const last = recent[0]
161 const hoursAgo = Math.round((now - new Date(last.timestamp || last.date).getTime()) / 1000 / 3600)
162 lines.push(`Last session: ${hoursAgo}h ago, ${last.turns} turns, ${last.corrections} corrections`)
163 if (last.trustedScore != null) lines.push(` trusted-score: ${last.trustedScore}`)
164
165 // Unresolved loops
166 if (unresolvedLoops.length > 0) {
167 lines.push(`Unresolved from prior sessions: ${unresolvedLoops.length}`)
168 for (const l of unresolvedLoops.slice(0, 3)) {
169 const firstCorr = (l.corrections && l.corrections[0] || '').slice(0, 80)
170 lines.push(` ✗ [${l.type}] "${firstCorr}..."`)
171 }
172 }
173
174 // Top files across recent sessions (likely still relevant)
175 const fileCount = {}
176 for (const s of recent) {
177 // We don't have per-session editedFiles here but loop filesTried is a proxy
178 const sLoops = (unresolvedLoops.length > 0)
179 ? [] // skip if we already reported
180 : []
181 }
182
183 process.stderr.write(lines.join('\n') + '\n')
184}
185
186main().catch(() => process.exit(0))
atomspace/strategic-rules.js +159 -0
……@@ -0,0 +1,159 @@
1/**
2 * strategic-rules.js — Situational rules that fire based on session STATE
3 *
4 * Unlike file-level rules, these trigger on behavioral patterns:
5 * - stuck-loop: 5+ turns without approval
6 * - high-tool-rate: many tools, little progress
7 * - reset-and-re-read: multiple corrections in a row
8 * - unfamiliar-territory: editing a module with few prior sessions
9 * - context-saturation: long session getting tired
10 * - repeating-failed-approach: similar actions after recent correction
11 *
12 * Consumer: attention-router loads these and injects relevant ones into prompts.
13 */
14
15const fs = require('fs')
16
17const STATE_FILE = '/tmp/claude-session-score.json'
18
19/**
20 * Read live session state.
21 */
22function readSessionState () {
23 try { return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) }
24 catch { return null }
25}
26
27/**
28 * All strategic rules. Each has:
29 * id: stable identifier
30 * condition(state): returns true if rule should fire
31 * message: one-line guidance
32 * severity: low / med / high (affects ranking in token budget)
33 */
34const STRATEGIC_RULES = [
35 {
36 id: 'stuck-loop',
37 condition: (state) => {
38 if (!state) return false
39 // Use PROBLEM-turns (resets on approval/proceed/new-problem/command).
40 const problemTurns = state._problemTurns != null
41 ? state._problemTurns
42 : ((state.turns || 0) - (state._lastApprovalTurn || 0))
43 // Must be stuck AND have had a RECENT correction (not session-total).
44 // _recentCorrections is maintained as last 5 correction texts.
45 const recentCorrections = (state._recentCorrections || []).length
46 return problemTurns >= 5 && recentCorrections >= 1
47 },
48 message: '5+ turns on this problem with a recent correction. STOP and re-read the plan. Something is wrong.',
49 severity: 'high'
50 },
51 {
52 id: 'reset-and-re-read',
53 condition: (state) => {
54 if (!state) return false
55 // 2+ corrections in last 3 turns
56 const recent = (state._recentCorrections || []).slice(-3)
57 return recent.length >= 2
58 },
59 message: 'Multiple corrections in a row. Stop. Re-read the user prompt and the file completely before acting.',
60 severity: 'high'
61 },
62 {
63 id: 'high-tool-rate',
64 condition: (state) => {
65 if (!state) return false
66 const ratio = state.turns > 0 ? (state.toolCalls || 0) / state.turns : 0
67 return ratio > 15 // 15+ tools per turn = fumbling
68 },
69 message: 'High tool-call rate per turn. You may be fumbling — pause and plan the next step.',
70 severity: 'med'
71 },
72 {
73 id: 'unresolved-loop-active',
74 condition: (state) => {
75 return state && state._loopActive === true
76 },
77 message: 'Active correction loop detected. The usual-approach is failing. Try a DIFFERENT angle.',
78 severity: 'high'
79 },
80 {
81 id: 'context-saturation',
82 condition: (state) => {
83 if (!state) return false
84 // Fire only when session is long AND has few completions relative to length.
85 // Use approvals count (always present) as the completion signal.
86 // Many approvals in a long session = successful long work, not saturation.
87 const turns = state.turns || 0
88 const approvals = state.approvals || 0
89 if (turns < 80) return false
90 // Expected completion rate: at least 1 approval per 20 turns
91 return approvals < Math.floor(turns / 20)
92 },
93 message: 'Long session with few completions relative to length. Context may be saturated. Consider wrapping up.',
94 severity: 'med'
95 },
96 {
97 id: 'declining-trajectory',
98 condition: (state) => {
99 return state && state.trajectory === 'declining'
100 },
101 message: 'Session trajectory is declining. Last turns worse than earlier. Regroup.',
102 severity: 'med'
103 },
104 {
105 id: 'low-approval-rate',
106 condition: (state) => {
107 if (!state || state.turns < 10) return false
108 const approvalRate = state.turns > 0 ? (state.approvals || 0) / state.turns : 0
109 const correctionRate = state.turns > 0 ? (state.corrections || 0) / state.turns : 0
110 return correctionRate > approvalRate * 2 && (state.corrections || 0) >= 3
111 },
112 message: 'Corrections significantly outpacing approvals. The direction isn\'t landing. Re-examine assumptions.',
113 severity: 'med'
114 }
115]
116
117/**
118 * Evaluate all rules against current session state.
119 * Returns array of { id, message, severity } for rules that fire.
120 */
121function evaluateRules (state) {
122 if (!state) return []
123 const firing = []
124 for (const rule of STRATEGIC_RULES) {
125 try {
126 if (rule.condition(state)) {
127 firing.push({ id: rule.id, message: rule.message, severity: rule.severity })
128 }
129 } catch {}
130 }
131 // Sort by severity: high first
132 const sevOrder = { high: 0, med: 1, low: 2 }
133 firing.sort((a, b) => (sevOrder[a.severity] ?? 3) - (sevOrder[b.severity] ?? 3))
134 return firing
135}
136
137/**
138 * Check which strategic rules fire right now based on live session state.
139 */
140function evaluateNow () {
141 const state = readSessionState()
142 return evaluateRules(state)
143}
144
145// CLI — check live session and print firing rules
146if (require.main === module) {
147 const firing = evaluateNow()
148 if (firing.length === 0) {
149 console.log('No strategic rules firing. Session is on track.')
150 } else {
151 console.log('Strategic rules firing:')
152 for (const r of firing) {
153 const marker = r.severity === 'high' ? '‼ ' : r.severity === 'med' ? '⚠ ' : '· '
154 console.log(`${marker}[${r.id}] ${r.message}`)
155 }
156 }
157}
158
159module.exports = { STRATEGIC_RULES, evaluateRules, evaluateNow }
atomspace/stream-correction.js +199 -0
……@@ -0,0 +1,199 @@
1#!/usr/bin/env node
2
3/**
4 * stream-correction.js — Stream corrections into AtomSpace in real-time
5 *
6 * Runs parallel to detect-correction.js as an async UserPromptSubmit hook.
7 * On detected correction, creates a live atom linked to current session.
8 *
9 * Silent if:
10 * - CogServer not running
11 * - No session atom (session-start hook didn't fire)
12 * - No correction pattern matched
13 */
14
15const fs = require('fs')
16const path = require('path')
17const { isCogServerUp, evalScheme } = require('./cogserver')
18
19const SESSION_FILE = '/tmp/claude-atomspace-session'
20const SESSION_STATE_FILE = '/tmp/claude-session-score.json'
21
22const CORRECTION_PATTERNS = [
23 /\b(no|wrong|not that|that's not|incorrect|stop doing)\b/i,
24 /\b(i said|i meant|i asked for|that's wrong)\b/i,
25 /\b(it's actually|you should use|the function is|look at|use .+ instead)\b/i,
26 /\b(again|already told you|how many times|we discussed this)\b/i,
27 /\b(don't ever|never do|stop|please don't|do not)\b/i
28]
29
30const APPROVAL_PATTERNS = [
31 /\b(perfect|exactly|great|yes|lgtm|looks good|nice|thanks)\b/i
32]
33
34function esc (s) {
35 return String(s || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, ' ')
36}
37
38function readSessionId () {
39 try { return fs.readFileSync(SESSION_FILE, 'utf8').trim() } catch { return null }
40}
41
42function readCurrentTurn () {
43 try {
44 const state = JSON.parse(fs.readFileSync(SESSION_STATE_FILE, 'utf8'))
45 return state.turns || 0
46 } catch { return 0 }
47}
48
49function readRecentEdits () {
50 try {
51 const state = JSON.parse(fs.readFileSync(SESSION_STATE_FILE, 'utf8'))
52 return state.editedFiles || state._lastTurnEdits || []
53 } catch { return [] }
54}
55
56/**
57 * Query AtomSpace for files edited in this session (live source of truth).
58 */
59async function getSessionEditedFiles (sessionId) {
60 try {
61 const result = await evalScheme(
62 `(cog-execute! (Meet (TypedVariable (Variable "$f") (Type (quote ConceptNode))) (Present (EdgeLink (BondNode "session-edited") (ListLink (ConceptNode "session:${sessionId}") (Variable "$f"))))))`)
63 const matches = (result || '').match(/(?:Concept|ConceptNode) "file:([^"]+)"/g) || []
64 return matches.map(m => m.match(/"file:([^"]+)"/)[1])
65 } catch { return [] }
66}
67
68async function main () {
69 // Read prompt
70 let input = ''
71 for await (const chunk of process.stdin) input += chunk
72
73 let data
74 try { data = JSON.parse(input) } catch { process.exit(0) }
75
76 const message = data.prompt || data.user_prompt || data.message || ''
77 if (!message || message.length < 3) process.exit(0)
78
79 // Skip analyzer prompts (prevent feedback loop)
80 if (message.includes('You are analyzing a user') ||
81 message.includes('REGEX CLASSIFIED AS') ||
82 message.includes('respond with EXACTLY one line')) {
83 process.exit(0)
84 }
85
86 const isCorrection = CORRECTION_PATTERNS.some(p => p.test(message))
87 const isApproval = APPROVAL_PATTERNS.some(p => p.test(message))
88 if (!isCorrection && !isApproval) process.exit(0)
89
90 // Check if CogServer + session are ready
91 if (!await isCogServerUp()) process.exit(0)
92 const sessionId = readSessionId()
93 if (!sessionId) process.exit(0)
94
95 const turn = readCurrentTurn()
96 const timestamp = Math.floor(Date.now() / 1000)
97 const type = isCorrection ? 'correction' : 'approval'
98 const truncated = message.slice(0, 300)
99
100 // Create live correction/approval atom
101 const eventId = `live:${type}:${sessionId}:${timestamp}`
102 const eventNode = `(ConceptNode "${esc(eventId)}")`
103 const sessionNode = `(ConceptNode "session:${sessionId}")`
104
105 const exprs = [
106 eventNode,
107 `(cog-set-value! ${eventNode} (Predicate "kind") (StringValue "${type}"))`,
108 `(cog-set-value! ${eventNode} (Predicate "text") (StringValue "${esc(truncated)}"))`,
109 `(cog-set-value! ${eventNode} (Predicate "turn") (FloatValue ${turn}))`,
110 `(cog-set-value! ${eventNode} (Predicate "timestamp") (FloatValue ${timestamp}))`,
111 // Link event → session
112 `(EdgeLink (BondNode "${type}-in-session") (ListLink ${eventNode} ${sessionNode}))`
113 ]
114
115 // Link event → recently edited files (from live AtomSpace session state)
116 // Falls back to ratchet session state file if query fails
117 let recentFiles = await getSessionEditedFiles(sessionId)
118 if (recentFiles.length === 0) {
119 recentFiles = readRecentEdits().map(f => f
120 .replace(/^\/storage\/projects\/code\/hypercore-kb\/hyper-fleeky\//, '')
121 .replace(/^\/storage\/projects\/code\/hypercore-kb\//, '')
122 .replace(/^\/storage\/projects\/code\//, ''))
123 }
124 for (const normalPath of recentFiles.slice(0, 5)) {
125 if (!normalPath) continue
126 const fileNode = `(ConceptNode "file:${esc(normalPath)}")`
127 exprs.push(`(EdgeLink (BondNode "${type}-near-edit") (ListLink ${eventNode} ${fileNode}))`)
128 }
129
130 // Phase 3.3: Active rule proposal — when a correction happens, propose a
131 // candidate rule based on recent edits + correction text. Written to
132 // data/rule-candidates.jsonl for interactive review (never auto-applied).
133 if (isCorrection && recentFiles.length > 0) {
134 try {
135 const path = require('path')
136 const candidateFile = path.join(__dirname, '..', 'data', 'rule-candidates.jsonl')
137 const candidate = {
138 timestamp: new Date().toISOString(),
139 session: sessionId,
140 symptom: truncated,
141 triggerFiles: recentFiles.slice(0, 5),
142 suggestedRule: null, // filled in by review script or LLM later
143 status: 'pending-review'
144 }
145 // Infer a rough rule suggestion
146 const sampleFile = recentFiles[0]
147 if (sampleFile) {
148 candidate.suggestedRule =
149 `When editing ${sampleFile}, watch for: "${truncated.slice(0, 80)}"`
150 }
151 fs.appendFileSync(candidateFile, JSON.stringify(candidate) + '\n')
152 } catch {}
153 }
154
155 // Utility tracking: if this is a correction, find rules for recently-edited
156 // files and increment their utility-contradictions counter.
157 // Rules that were likely "shown" (file was edited) but didn't prevent the
158 // correction get a signal that they may be less useful in this context.
159 // NO DELETION — just a counter update.
160 if (isCorrection) {
161 const rulesToPenalize = new Set()
162 for (const normalPath of recentFiles.slice(0, 5)) {
163 if (!normalPath) continue
164 // Find rule atoms for this file
165 const ruleQuery = `(cog-execute! (Meet (TypedVariable (Variable "$r") (Type (quote ConceptNode))) (Present (EdgeLink (BondNode "rule-for-file") (ListLink (Variable "$r") (ConceptNode "file:${esc(normalPath)}"))))))`
166 try {
167 const result = await evalScheme(ruleQuery)
168 const matches = (result || '').match(/(?:Concept|ConceptNode) "(rule:[^"]+)"/g) || []
169 for (const m of matches) {
170 const ruleName = m.match(/"(rule:[^"]+)"/)[1]
171 rulesToPenalize.add(ruleName)
172 }
173 } catch {}
174 }
175
176 // Increment utility-contradictions for each penalized rule
177 for (const ruleName of rulesToPenalize) {
178 const ruleAtom = `(ConceptNode "${esc(ruleName)}")`
179 exprs.push(
180 `(cog-set-value! ${ruleAtom} (Predicate "utility-contradictions") ` +
181 `(FloatValue (+ 1 (let ((v (cog-value ${ruleAtom} (Predicate "utility-contradictions")))) ` +
182 `(if v (cog-value-ref v 0) 0)))))`
183 )
184 }
185 }
186
187 // Update session-level counters
188 if (isCorrection) {
189 exprs.push(`(cog-set-value! ${sessionNode} (Predicate "correction-count") (FloatValue ${turn}))`)
190 }
191
192 try {
193 await evalScheme('(begin ' + exprs.join(' ') + ')')
194 } catch {}
195
196 process.exit(0)
197}
198
199main().catch(() => process.exit(0))
atomspace/stream-tool.js +83 -0
……@@ -0,0 +1,83 @@
1#!/usr/bin/env node
2
3/**
4 * stream-tool.js — Stream tool calls (Edit/Write) into AtomSpace in real-time
5 *
6 * Runs as async PostToolUse hook. On Edit/Write, creates an edge linking
7 * the file to the current session. Used by preflight queries to detect
8 * "you just edited this file" and "correction just happened near this file."
9 */
10
11const fs = require('fs')
12const { isCogServerUp, evalScheme } = require('./cogserver')
13
14const SESSION_FILE = '/tmp/claude-atomspace-session'
15
16function esc (s) {
17 return String(s || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"')
18}
19
20function readSessionId () {
21 try { return fs.readFileSync(SESSION_FILE, 'utf8').trim() } catch { return null }
22}
23
24function normalizePath (p) {
25 if (!p) return ''
26 return p
27 .replace(/^\/storage\/projects\/code\/hypercore-kb\/hyper-fleeky\//, '')
28 .replace(/^\/storage\/projects\/code\/hypercore-kb\//, '')
29 .replace(/^\/storage\/projects\/code\//, '')
30}
31
32async function main () {
33 let input = ''
34 for await (const chunk of process.stdin) input += chunk
35
36 let data
37 try { data = JSON.parse(input) } catch { process.exit(0) }
38
39 const toolName = data.tool_name || data.tool
40 if (toolName !== 'Edit' && toolName !== 'Write') process.exit(0)
41
42 const toolInput = data.tool_input || {}
43 const filePath = toolInput.file_path || ''
44 if (!filePath) process.exit(0)
45
46 // Only track edits that actually succeeded (check tool_response if available)
47 const response = data.tool_response || {}
48 if (response.isError) process.exit(0)
49
50 if (!await isCogServerUp()) process.exit(0)
51 const sessionId = readSessionId()
52 if (!sessionId) process.exit(0)
53
54 const normalPath = normalizePath(filePath)
55 if (!normalPath) process.exit(0)
56
57 const timestamp = Math.floor(Date.now() / 1000)
58 const sessionNode = `(ConceptNode "session:${sessionId}")`
59 const fileNode = `(ConceptNode "file:${esc(normalPath)}")`
60 const editId = `live:edit:${sessionId}:${timestamp}`
61 const editNode = `(ConceptNode "${esc(editId)}")`
62
63 const exprs = [
64 fileNode,
65 editNode,
66 `(cog-set-value! ${editNode} (Predicate "kind") (StringValue "${toolName}"))`,
67 `(cog-set-value! ${editNode} (Predicate "timestamp") (FloatValue ${timestamp}))`,
68 // Link edit event → session
69 `(EdgeLink (BondNode "edit-in-session") (ListLink ${editNode} ${sessionNode}))`,
70 // Link edit event → file
71 `(EdgeLink (BondNode "edit-of-file") (ListLink ${editNode} ${fileNode}))`,
72 // Direct: session edited file (deduplicated because atoms are unique)
73 `(EdgeLink (BondNode "session-edited") (ListLink ${sessionNode} ${fileNode}))`
74 ]
75
76 try {
77 await evalScheme('(begin ' + exprs.join(' ') + ')')
78 } catch {}
79
80 process.exit(0)
81}
82
83main().catch(() => process.exit(0))
atomspace/success-memory.js +222 -0
……@@ -0,0 +1,222 @@
1#!/usr/bin/env node
2
3/**
4 * success-memory.js — Capture successful approaches, not just failures
5 *
6 * Shifts ratchet from failure-only → failure + success memory.
7 * For each approval in a session, snapshot the 5 turns before it:
8 * - Files edited
9 * - Tool pattern (sequence of tool names)
10 * - Dominant sensor values
11 * - Whether preceded by corrections (resolution) or clean (validation)
12 *
13 * Creates success: atoms with bonds to files and topics.
14 * Attention-router can query these to inject "past approach that worked".
15 */
16
17const fs = require('fs')
18const path = require('path')
19const { evalScheme, isCogServerUp } = require('./cogserver')
20
21const DATA_DIR = path.join(__dirname, '..', 'data')
22const LOOKBACK = 5 // turns before approval to capture
23const MIN_TOOL_CALLS = 3 // need some actual activity to count
24
25function esc (s) {
26 return String(s || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, ' ')
27}
28
29function readJSON (name) {
30 const file = path.join(DATA_DIR, name)
31 if (!fs.existsSync(file)) return null
32 return JSON.parse(fs.readFileSync(file, 'utf8'))
33}
34
35/**
36 * Extract success patterns from a session.
37 * A "success" = an approval turn preceded by N turns of actual work.
38 *
39 * Returns array of success records:
40 * { sessionId, approvalTurn, windowTurns, files, tools, correctedFirst, score }
41 */
42function extractSuccesses (session) {
43 const turns = session.turns || []
44 const successes = []
45
46 for (let i = 0; i < turns.length; i++) {
47 if (!turns[i].isApproval) continue
48
49 // Look back LOOKBACK turns
50 const start = Math.max(0, i - LOOKBACK)
51 const window = turns.slice(start, i)
52 if (window.length === 0) continue
53
54 // Must have actual activity
55 const totalToolCalls = window.reduce((s, t) => s + (t.toolCalls || 0), 0)
56 if (totalToolCalls < MIN_TOOL_CALLS) continue
57
58 // Files edited in the window
59 const files = new Set()
60 for (const t of window) {
61 for (const f of (t.editFiles || [])) files.add(f)
62 }
63
64 // Was this success preceded by a correction? (resolution vs clean validation)
65 const correctedFirst = window.some(t => t.isCorrection)
66
67 // Dominant sensor averages over window
68 const sensorAggs = {}
69 const trustedKeys = ['correction', 'approval', 'userProvidesAnswer', 'buildErrors', 'editSurvival']
70 for (const t of window) {
71 if (!t.sensors) continue
72 for (const k of trustedKeys) {
73 if (t.sensors[k] != null) {
74 if (!sensorAggs[k]) sensorAggs[k] = []
75 sensorAggs[k].push(t.sensors[k])
76 }
77 }
78 }
79 const sensors = {}
80 for (const [k, vals] of Object.entries(sensorAggs)) {
81 sensors[k] = Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 100) / 100
82 }
83
84 // Average per-turn score in the window
85 const scores = window.map(t => t.score || 0.5)
86 const avgScore = scores.reduce((a, b) => a + b, 0) / scores.length
87
88 successes.push({
89 sessionId: session.sessionId,
90 date: session.date,
91 cwd: session.cwd,
92 approvalTurn: i,
93 windowSize: window.length,
94 files: [...files],
95 totalToolCalls,
96 correctedFirst,
97 sensors,
98 avgScore: Math.round(avgScore * 1000) / 1000,
99 approvalText: (turns[i].text || '').slice(0, 150)
100 })
101 }
102
103 return successes
104}
105
106/**
107 * Classify an approval's "shape" — what kind of success was it?
108 * resolution: preceded by correction (we fixed something)
109 * validation: clean path (approach worked first try)
110 * partial: mid-session checkpoint (not session-end)
111 */
112function classifySuccess (s) {
113 if (s.correctedFirst) return 'resolution'
114 return 'validation'
115}
116
117/**
118 * Generate Scheme to ingest a success record.
119 */
120function successToScheme (success) {
121 const exprs = []
122 const sid = success.sessionId
123 const turn = success.approvalTurn
124 const id = `success:${sid}:${turn}`
125 const node = `(ConceptNode "${esc(id)}")`
126 const shape = classifySuccess(success)
127
128 exprs.push(node)
129 exprs.push(`(cog-set-value! ${node} (Predicate "kind") (StringValue "${shape}"))`)
130 exprs.push(`(cog-set-value! ${node} (Predicate "date") (StringValue "${esc(success.date)}"))`)
131 exprs.push(`(cog-set-value! ${node} (Predicate "turn") (FloatValue ${turn}))`)
132 exprs.push(`(cog-set-value! ${node} (Predicate "window-size") (FloatValue ${success.windowSize}))`)
133 exprs.push(`(cog-set-value! ${node} (Predicate "tool-calls") (FloatValue ${success.totalToolCalls}))`)
134 exprs.push(`(cog-set-value! ${node} (Predicate "avg-score") (FloatValue ${success.avgScore}))`)
135 exprs.push(`(cog-set-value! ${node} (Predicate "approval-text") (StringValue "${esc(success.approvalText)}"))`)
136
137 // Link to session
138 exprs.push(`(EdgeLink (BondNode "success-in-session") (ListLink ${node} (ConceptNode "session-archive:${esc(sid)}")))`)
139
140 // Link to each file edited in the window (preferred approach)
141 for (const f of success.files.slice(0, 10)) {
142 const normalPath = f
143 .replace(/^\/storage\/projects\/code\/hypercore-kb\/hyper-fleeky\//, '')
144 .replace(/^\/storage\/projects\/code\/hypercore-kb\//, '')
145 .replace(/^\/storage\/projects\/code\//, '')
146 if (!normalPath) continue
147 const fileNode = `(ConceptNode "file:${esc(normalPath)}")`
148 exprs.push(fileNode)
149 exprs.push(`(EdgeLink (BondNode "success-touched") (ListLink ${node} ${fileNode}))`)
150 }
151
152 return exprs
153}
154
155async function main () {
156 const dryRun = process.argv.includes('--dry-run')
157
158 const detail = readJSON('fused-turns-detail.json')
159 if (!detail) { console.log('No fused-turns-detail'); process.exit(1) }
160
161 const sessions = detail.sessions || detail
162 const allSuccesses = []
163 for (const s of sessions) {
164 allSuccesses.push(...extractSuccesses(s))
165 }
166
167 // Distribution
168 const byShape = {}
169 const byFile = {}
170 let totalFiles = 0
171 for (const s of allSuccesses) {
172 const shape = classifySuccess(s)
173 byShape[shape] = (byShape[shape] || 0) + 1
174 for (const f of s.files) {
175 byFile[f] = (byFile[f] || 0) + 1
176 totalFiles++
177 }
178 }
179
180 console.log(`Extracted ${allSuccesses.length} success patterns from ${sessions.length} sessions`)
181 console.log(` Unique files involved in successes: ${Object.keys(byFile).length}`)
182 console.log('')
183 console.log('By shape:')
184 for (const [shape, count] of Object.entries(byShape).sort((a, b) => b[1] - a[1])) {
185 console.log(` ${shape.padEnd(12)} ${count}`)
186 }
187
188 console.log('\nTop files in successes (top 10):')
189 const topFiles = Object.entries(byFile).sort((a, b) => b[1] - a[1]).slice(0, 10)
190 for (const [f, c] of topFiles) {
191 const short = f.replace(/^\/storage\/projects\/code\/hypercore-kb\/hyper-fleeky\//, '')
192 console.log(` ${String(c).padStart(3)} ${short}`)
193 }
194
195 if (dryRun) {
196 console.log('\n--dry-run: not ingesting')
197 return
198 }
199
200 if (!await isCogServerUp()) {
201 console.log('\nCogServer not running, skipping ingest')
202 return
203 }
204
205 // Ingest
206 const allExprs = allSuccesses.flatMap(successToScheme)
207 console.log(`\nIngesting ${allExprs.length} Scheme expressions...`)
208 const BATCH = 50
209 let sent = 0
210 for (let i = 0; i < allExprs.length; i += BATCH) {
211 const batch = allExprs.slice(i, i + BATCH)
212 await evalScheme('(begin ' + batch.join(' ') + ')')
213 sent += batch.length
214 }
215 console.log(`Stored: ${sent} atoms/edges for ${allSuccesses.length} successes`)
216}
217
218if (require.main === module) {
219 main().catch(err => { console.error(err.message); process.exit(1) })
220}
221
222module.exports = { extractSuccesses, classifySuccess }
atomspace/success-promote.js +174 -0
……@@ -0,0 +1,174 @@
1#!/usr/bin/env node
2
3/**
4 * success-promote.js — Promote repeated success patterns to preferred-approach rules
5 *
6 * When N successes share context (same files, same shape, same module), that's
7 * a repeatable pattern worth preserving as guidance — not just a one-off success.
8 *
9 * Process:
10 * 1. Group success records by (primary-file, shape) — shape=resolution/validation
11 * 2. If ≥3 successes in a group, create a preferred-approach rule
12 * 3. The rule surfaces in attention-router when editing the primary-file
13 *
14 * Output: new meta:preferred-approach: atoms linked to files.
15 */
16
17const fs = require('fs')
18const path = require('path')
19const { evalScheme, isCogServerUp } = require('./cogserver')
20const { extractSuccesses, classifySuccess } = require('./success-memory')
21
22const DATA_DIR = path.join(__dirname, '..', 'data')
23const MIN_SUCCESSES = 3
24
25function esc (s) {
26 return String(s || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, ' ')
27}
28
29function normalizePath (f) {
30 if (!f) return ''
31 return f
32 .replace(/^\/storage\/projects\/code\/hypercore-kb\/hyper-fleeky\//, '')
33 .replace(/^\/storage\/projects\/code\/hypercore-kb\//, '')
34 .replace(/^\/storage\/projects\/code\//, '')
35}
36
37/**
38 * Group successes into promotable patterns.
39 * Group by (normalizedPath of primary file, shape).
40 */
41function groupSuccesses (successes) {
42 const groups = new Map()
43 for (const s of successes) {
44 // Primary file = first file edited in window
45 const primary = normalizePath(s.files[0])
46 if (!primary) continue
47 const shape = classifySuccess(s)
48 const key = `${primary}::${shape}`
49 if (!groups.has(key)) groups.set(key, { primary, shape, successes: [] })
50 groups.get(key).successes.push(s)
51 }
52 return groups
53}
54
55/**
56 * For each group with ≥N successes, create a preferred-approach atom.
57 */
58function buildPreferredApproaches (groups) {
59 const approaches = []
60 for (const [key, group] of groups) {
61 if (group.successes.length < MIN_SUCCESSES) continue
62
63 // Aggregate context: all secondary files touched across successes
64 const secondaryFiles = {}
65 for (const s of group.successes) {
66 for (const f of s.files.slice(1, 5)) {
67 const np = normalizePath(f)
68 if (np) secondaryFiles[np] = (secondaryFiles[np] || 0) + 1
69 }
70 }
71 // Top 3 secondary files
72 const topSecondary = Object.entries(secondaryFiles)
73 .sort((a, b) => b[1] - a[1])
74 .slice(0, 3)
75 .map(([f]) => f)
76
77 // Avg metrics
78 const avgScore = group.successes.reduce((s, x) => s + x.avgScore, 0) / group.successes.length
79 const sessions = [...new Set(group.successes.map(s => s.sessionId))]
80
81 approaches.push({
82 id: `preferred:${group.primary}:${group.shape}`,
83 primaryFile: group.primary,
84 shape: group.shape,
85 successCount: group.successes.length,
86 sessionCount: sessions.length,
87 avgScore: Math.round(avgScore * 1000) / 1000,
88 secondaryFiles: topSecondary,
89 // Sample approval text (first approval with text)
90 sampleApproval: (group.successes.find(s => s.approvalText) || group.successes[0]).approvalText
91 })
92 }
93 return approaches
94}
95
96function approachToScheme (a) {
97 const exprs = []
98 const node = `(ConceptNode "${esc(a.id)}")`
99
100 exprs.push(node)
101 exprs.push(`(cog-set-value! ${node} (Predicate "kind") (StringValue "preferred-approach"))`)
102 exprs.push(`(cog-set-value! ${node} (Predicate "shape") (StringValue "${a.shape}"))`)
103 exprs.push(`(cog-set-value! ${node} (Predicate "success-count") (FloatValue ${a.successCount}))`)
104 exprs.push(`(cog-set-value! ${node} (Predicate "session-count") (FloatValue ${a.sessionCount}))`)
105 exprs.push(`(cog-set-value! ${node} (Predicate "avg-score") (FloatValue ${a.avgScore}))`)
106 exprs.push(`(cog-set-value! ${node} (Predicate "sample-approval") (StringValue "${esc(a.sampleApproval || '')}"))`)
107
108 // Link to primary file (PREFERRED approach when editing this file)
109 const primaryAtom = `(ConceptNode "file:${esc(a.primaryFile)}")`
110 exprs.push(`(EdgeLink (BondNode "preferred-for-file") (ListLink ${node} ${primaryAtom}))`)
111
112 // Link to secondary files (often edited together)
113 for (const sf of a.secondaryFiles) {
114 exprs.push(`(EdgeLink (BondNode "preferred-secondary") (ListLink ${node} (ConceptNode "file:${esc(sf)}")))`)
115 }
116
117 return exprs
118}
119
120async function main () {
121 const dryRun = process.argv.includes('--dry-run')
122
123 const detail = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'fused-turns-detail.json'), 'utf8'))
124 const sessions = detail.sessions || detail
125
126 // Extract all successes
127 const allSuccesses = []
128 for (const s of sessions) {
129 allSuccesses.push(...extractSuccesses(s))
130 }
131 console.log(`Total successes: ${allSuccesses.length}`)
132
133 // Group and filter
134 const groups = groupSuccesses(allSuccesses)
135 const approaches = buildPreferredApproaches(groups)
136
137 console.log(`Groups with ≥${MIN_SUCCESSES} successes: ${approaches.length}`)
138
139 console.log('\n── Top 15 preferred approaches (by success count) ──')
140 const sorted = [...approaches].sort((a, b) => b.successCount - a.successCount)
141 for (const a of sorted.slice(0, 15)) {
142 console.log(` ${String(a.successCount).padStart(3)} successes [${a.shape}] ${a.primaryFile} (${a.sessionCount} sessions, avg score ${a.avgScore})`)
143 if (a.secondaryFiles.length > 0) {
144 console.log(` + often touches: ${a.secondaryFiles.slice(0, 2).join(', ')}`)
145 }
146 }
147
148 if (dryRun) {
149 console.log('\n--dry-run: not ingesting')
150 return
151 }
152
153 if (!await isCogServerUp()) {
154 console.log('\nCogServer not running, skipping ingest')
155 return
156 }
157
158 const allExprs = approaches.flatMap(approachToScheme)
159 console.log(`\nIngesting ${allExprs.length} Scheme expressions...`)
160 const BATCH = 50
161 let sent = 0
162 for (let i = 0; i < allExprs.length; i += BATCH) {
163 const batch = allExprs.slice(i, i + BATCH)
164 await evalScheme('(begin ' + batch.join(' ') + ')')
165 sent += batch.length
166 }
167 console.log(`Stored: ${sent} atoms/edges for ${approaches.length} preferred approaches`)
168}
169
170if (require.main === module) {
171 main().catch(err => { console.error(err.message); process.exit(1) })
172}
173
174module.exports = { groupSuccesses, buildPreferredApproaches }
atomspace/test/bench.js +164 -0
……@@ -0,0 +1,164 @@
1#!/usr/bin/env node
2
3/**
4 * bench.js — Benchmarks for the AtomSpace knowledge layer
5 *
6 * Measures:
7 * 1. CogServer connection latency
8 * 2. Single eval round-trip
9 * 3. Ingest throughput (atoms/sec)
10 * 4. Query latency (blastRadius, similarLoops, riskAssessment)
11 * 5. MeetLink vs incoming-set traversal
12 * 6. Graph size and composition
13 * 7. Preflight hook end-to-end latency
14 *
15 * Usage: node atomspace/test/bench.js
16 * Requires: CogServer running on port 17001
17 */
18
19const { isCogServerUp, evalScheme } = require('../cogserver')
20const { blastRadius, similarLoops, riskAssessment } = require('../query')
21const { ingestCodetree, treeToScheme, scanCodetree } = require('../ingest-codetree')
22const { ingestRatchet } = require('../ingest-ratchet')
23const { execSync } = require('child_process')
24const path = require('path')
25
26async function timeIt (name, fn, iterations = 1) {
27 const times = []
28 for (let i = 0; i < iterations; i++) {
29 const start = Date.now()
30 await fn()
31 times.push(Date.now() - start)
32 }
33 times.sort((a, b) => a - b)
34 const avg = times.reduce((a, b) => a + b, 0) / times.length
35 const min = times[0]
36 const max = times[times.length - 1]
37 const median = times[Math.floor(times.length / 2)]
38 const rate = iterations > 1 ? ` (${(1000 / avg).toFixed(0)} ops/sec)` : ''
39 console.log(` ${name.padEnd(40)} avg=${avg.toFixed(0)}ms min=${min}ms max=${max}ms median=${median}ms${rate}`)
40 return { avg, min, max, median }
41}
42
43async function main () {
44 console.log('AtomSpace Knowledge Layer — Benchmarks')
45 console.log('=' .repeat(60))
46
47 const up = await isCogServerUp()
48 if (!up) {
49 console.log('\nCogServer not running. Start it first: cogserver &')
50 process.exit(1)
51 }
52
53 // ── 1. Connection ──
54 console.log('\n── Connection ──')
55 await timeIt('isCogServerUp() probe', () => isCogServerUp(), 20)
56 await timeIt('evalScheme("(+ 1 1)") round-trip', () => evalScheme('(+ 1 1)'), 20)
57
58 // ── 2. Graph size ──
59 console.log('\n── Graph Size ──')
60 const counts = await evalScheme('(cog-report-counts)')
61 console.log(' ' + counts)
62
63 const edgeCount = await evalScheme('(length (cog-get-atoms (quote EdgeLink)))')
64 const conceptCount = await evalScheme('(length (cog-get-atoms (quote ConceptNode)))')
65 console.log(` ConceptNodes: ${conceptCount} EdgeLinks: ${edgeCount}`)
66
67 const bonds = ['contains', 'imports', 'depends-on', 'exports', 'loop-in-file', 'rule-for-file']
68 for (const b of bonds) {
69 const c = await evalScheme(`(length (cog-incoming-by-type (BondNode "${b}") (quote EdgeLink)))`)
70 console.log(` ${b}: ${c} edges`)
71 }
72
73 // ── 3. Query latency ──
74 console.log('\n── Query Latency ──')
75
76 const testFiles = [
77 'hyper-overlay/index.js',
78 'hypernote/lib/auth.js',
79 'pear-git-v3/lib/pear-git.js',
80 'pear-git/pear-git-server.js',
81 'codetree/lib/parser.js'
82 ]
83
84 for (const f of testFiles) {
85 await timeIt(`blastRadius(${f.split('/').pop()})`,
86 () => blastRadius(f), 5)
87 }
88
89 await timeIt('similarLoops(3 files)',
90 () => similarLoops(['hyper-overlay/index.js', 'hypernote/lib/auth.js', 'pear-git/pear-git-server.js']), 5)
91
92 await timeIt('riskAssessment(3 files)',
93 () => riskAssessment(['hyper-overlay/index.js', 'pear-git-v3/lib/pear-git.js', 'hypernote/lib/auth.js']), 5)
94
95 // ── 4. Raw MeetLink latency ──
96 console.log('\n── Raw MeetLink vs Incoming-Set ──')
97
98 await timeIt('MeetLink: children of mod:hypernote',
99 () => evalScheme('(cog-execute! (Meet (TypedVariable (Variable "$f") (Type (quote ConceptNode))) (Present (EdgeLink (BondNode "contains") (ListLink (ConceptNode "mod:hypernote") (Variable "$f"))))))'), 10)
100
101 await timeIt('incoming-set: edges of BondNode',
102 () => evalScheme('(length (cog-incoming-by-type (BondNode "contains") (quote EdgeLink)))'), 10)
103
104 await timeIt('incoming-set: ListLinks for a file',
105 () => evalScheme('(length (cog-incoming-by-type (ConceptNode "file:hyper-overlay/index.js") (quote ListLink)))'), 10)
106
107 // ── 5. Ingest throughput ──
108 console.log('\n── Ingest Throughput ──')
109
110 // Codetree scan (no ingest, just measure scan speed)
111 const scanResult = await timeIt('codetree scan (JSON generation)',
112 () => { scanCodetree(); return true }, 1)
113
114 const tree = scanCodetree()
115 const exprs = treeToScheme(tree)
116 console.log(` Codetree: ${exprs.length} Scheme expressions from ${Object.keys(tree.modules || {}).length} modules`)
117
118 // Measure batch ingest speed
119 const batchSizes = [10, 50, 100]
120 for (const size of batchSizes) {
121 const batch = exprs.slice(0, size)
122 await timeIt(`ingest batch of ${size} expressions`,
123 () => evalScheme('(begin ' + batch.join(' ') + ')'), 3)
124 }
125
126 // Full ingest
127 const fullStart = Date.now()
128 await ingestRatchet()
129 const ratchetMs = Date.now() - fullStart
130 console.log(` Full ratchet ingest: ${ratchetMs}ms`)
131
132 // ── 6. Preflight hook end-to-end ──
133 console.log('\n── Preflight Hook End-to-End ──')
134
135 const hookTestFiles = [
136 '/storage/projects/code/hypercore-kb/hyper-fleeky/hyper-overlay/index.js',
137 '/storage/projects/code/hypercore-kb/hyper-fleeky/hypernote/lib/auth.js',
138 '/storage/projects/code/hypercore-kb/hyper-fleeky/codetree/lib/parser.js'
139 ]
140
141 for (const f of hookTestFiles) {
142 // Clear state so it doesn't skip
143 try { require('fs').unlinkSync('/tmp/claude-preflight-state.json') } catch {}
144
145 const label = f.split('/').pop()
146 await timeIt(`preflight hook (${label})`, async () => {
147 const input = JSON.stringify({ tool_name: 'Edit', tool_input: { file_path: f, old_string: 'x', new_string: 'y' } })
148 try {
149 execSync(`echo '${input}' | node ${path.resolve(__dirname, '../../preflight-review.js')}`, {
150 timeout: 5000, stdio: 'pipe'
151 })
152 } catch {}
153 }, 3)
154 }
155
156 // ── Summary ──
157 console.log('\n' + '=' .repeat(60))
158 console.log('Benchmark complete.')
159}
160
161main().catch(err => {
162 console.error('Bench failed:', err.message)
163 process.exit(1)
164})
atomspace/test/why-analysis.js +185 -0
……@@ -0,0 +1,185 @@
1#!/usr/bin/env node
2
3/**
4 * why-analysis.js — Discover WHY corrections happen using the combined graph
5 *
6 * Cross-domain analysis that neither codetree nor ratchet can do alone:
7 * 1. Do corrections cluster at module boundaries?
8 * 2. What file roles get corrected most?
9 * 3. What loop types dominate?
10 * 4. Which modules have worst correction density?
11 * 5. Are there structural patterns that predict corrections?
12 * 6. Rules that apply to structurally connected files
13 */
14
15const { evalScheme, isCogServerUp } = require('../cogserver')
16
17function parseList (str) {
18 if (!str) return []
19 return str.replace(/^"|"$/g, '').split('|').filter(Boolean)
20}
21
22async function main () {
23 if (!await isCogServerUp()) {
24 console.log('CogServer not running')
25 process.exit(1)
26 }
27
28 // Load srfi-1
29 await evalScheme('(use-modules (srfi srfi-1))')
30
31 console.log('=== WHY ANALYSIS — Correction Patterns from AtomSpace ===\n')
32
33 // ── 1. Module boundary analysis ──
34 console.log('── 1. Do corrections cluster at module boundaries? ──')
35
36 const loopFileStr = await evalScheme(
37 '(string-join (delete-duplicates (map (lambda (e) (cog-name (cog-outgoing-atom (cog-outgoing-atom e 1) 1))) (cog-incoming-by-type (BondNode "loop-in-file") (quote EdgeLink)))) "|")')
38 const loopFiles = new Set(parseList(loopFileStr))
39
40 const importFileStr = await evalScheme(
41 '(string-join (delete-duplicates (map (lambda (e) (cog-name (cog-outgoing-atom (cog-outgoing-atom e 1) 0))) (cog-incoming-by-type (BondNode "imports") (quote EdgeLink)))) "|")')
42 const importFiles = new Set(parseList(importFileStr))
43
44 const loopsAtBoundary = [...loopFiles].filter(f => importFiles.has(f)).length
45 const loopsInternal = loopFiles.size - loopsAtBoundary
46
47 console.log(` Files with loops: ${loopFiles.size}`)
48 console.log(` Files with loops AND cross-module imports: ${loopsAtBoundary}`)
49 console.log(` Files with loops but NO imports: ${loopsInternal}`)
50 if (loopFiles.size > 0) {
51 console.log(` Boundary correction rate: ${(loopsAtBoundary / loopFiles.size * 100).toFixed(0)}%`)
52 }
53
54 // ── 2. File role analysis ──
55 console.log('\n── 2. Which file roles get corrected most? ──')
56 const roleCounts = {}
57 for (const f of loopFiles) {
58 const role = await evalScheme(`(let ((v (cog-value (ConceptNode "${f}") (Predicate "role")))) (if v (cog-value-ref v 0) "unknown"))`)
59 const clean = (role || 'unknown').replace(/"/g, '')
60 roleCounts[clean] = (roleCounts[clean] || 0) + 1
61 }
62 for (const [role, count] of Object.entries(roleCounts).sort((a, b) => b[1] - a[1])) {
63 console.log(` ${String(count).padStart(4)} loop-files role=${role}`)
64 }
65
66 // ── 3. Loop type distribution ──
67 console.log('\n── 3. What types of loops dominate? ──')
68 const typeStr = await evalScheme(
69 '(string-join (map (lambda (e) (let ((n (cog-outgoing-atom (cog-outgoing-atom e 1) 0))) (let ((v (cog-value n (Predicate "type")))) (if v (cog-value-ref v 0) "unknown")))) (cog-incoming-by-type (BondNode "loop-in-file") (quote EdgeLink))) "|")')
70 const typeCounts = {}
71 for (const t of parseList(typeStr)) {
72 const clean = t.replace(/"/g, '')
73 typeCounts[clean] = (typeCounts[clean] || 0) + 1
74 }
75 for (const [type, count] of Object.entries(typeCounts).sort((a, b) => b[1] - a[1])) {
76 console.log(` ${String(count).padStart(4)} ${type}`)
77 }
78
79 // ── 4. Module correction density ──
80 console.log('\n── 4. Which modules have worst correction density? ──')
81 const containStr = await evalScheme(
82 '(string-join (map (lambda (e) (string-append (cog-name (cog-outgoing-atom (cog-outgoing-atom e 1) 0)) "::" (cog-name (cog-outgoing-atom (cog-outgoing-atom e 1) 1)))) (cog-incoming-by-type (BondNode "contains") (quote EdgeLink))) "|")')
83 const modFileCounts = {}
84 for (const pair of parseList(containStr)) {
85 const mod = pair.split('::')[0]
86 modFileCounts[mod] = (modFileCounts[mod] || 0) + 1
87 }
88
89 const modLoopCounts = {}
90 for (const f of loopFiles) {
91 for (const mod of Object.keys(modFileCounts)) {
92 const prefix = mod.replace('mod:', '')
93 if (f.includes(prefix + '/')) {
94 modLoopCounts[mod] = (modLoopCounts[mod] || 0) + 1
95 break
96 }
97 }
98 }
99
100 const modDensity = Object.entries(modFileCounts)
101 .map(([mod, files]) => ({
102 mod: mod.replace('mod:', ''),
103 files,
104 loops: modLoopCounts[mod] || 0,
105 density: (modLoopCounts[mod] || 0) / files
106 }))
107 .filter(m => m.files > 2)
108 .sort((a, b) => b.density - a.density)
109
110 for (const m of modDensity.slice(0, 12)) {
111 const bar = '█'.repeat(Math.round(m.density * 20))
112 console.log(` ${m.mod.padEnd(22)} ${(m.density * 100).toFixed(0).padStart(3)}% ${bar} (${m.loops} loops / ${m.files} files)`)
113 }
114
115 // ── 5. Structural risk patterns ──
116 console.log('\n── 5. Structural risk patterns ──')
117
118 // Files with exports that also have rules
119 const exportFileStr = await evalScheme(
120 '(string-join (delete-duplicates (map (lambda (e) (cog-name (cog-outgoing-atom (cog-outgoing-atom e 1) 0))) (cog-incoming-by-type (BondNode "exports") (quote EdgeLink)))) "|")')
121 const exportFiles = new Set(parseList(exportFileStr))
122
123 const ruleFileStr = await evalScheme(
124 '(string-join (delete-duplicates (map (lambda (e) (cog-name (cog-outgoing-atom (cog-outgoing-atom e 1) 1))) (cog-incoming-by-type (BondNode "rule-for-file") (quote EdgeLink)))) "|")')
125 const ruleFiles = new Set(parseList(ruleFileStr))
126
127 const exportWithRules = [...exportFiles].filter(f => ruleFiles.has(f))
128 const exportWithLoops = [...exportFiles].filter(f => loopFiles.has(f))
129
130 console.log(` Files that export public API: ${exportFiles.size}`)
131 console.log(` Of those, files with learned rules: ${exportWithRules.length} (${(exportWithRules.length / exportFiles.size * 100).toFixed(0)}%)`)
132 console.log(` Of those, files with correction loops: ${exportWithLoops.length} (${(exportWithLoops.length / exportFiles.size * 100).toFixed(0)}%)`)
133
134 // ── 6. Top risk files (combined structural + behavioral) ──
135 console.log('\n── 6. Highest risk files (exports + loops + rules) ──')
136
137 // Count rules per file
138 const ruleCountStr = await evalScheme(
139 '(string-join (map (lambda (e) (cog-name (cog-outgoing-atom (cog-outgoing-atom e 1) 1))) (cog-incoming-by-type (BondNode "rule-for-file") (quote EdgeLink))) "|")')
140 const ruleCountMap = {}
141 for (const f of parseList(ruleCountStr)) ruleCountMap[f] = (ruleCountMap[f] || 0) + 1
142
143 // Count loops per file
144 const loopCountStr = await evalScheme(
145 '(string-join (map (lambda (e) (cog-name (cog-outgoing-atom (cog-outgoing-atom e 1) 1))) (cog-incoming-by-type (BondNode "loop-in-file") (quote EdgeLink))) "|")')
146 const loopCountMap = {}
147 for (const f of parseList(loopCountStr)) loopCountMap[f] = (loopCountMap[f] || 0) + 1
148
149 // Combined risk score: loops * 3 + rules * 1 + exports * 2
150 const allFiles = new Set([...loopFiles, ...ruleFiles, ...exportFiles])
151 const riskScores = [...allFiles].map(f => ({
152 file: f.replace('file:', ''),
153 loops: loopCountMap[f] || 0,
154 rules: ruleCountMap[f] || 0,
155 exports: exportFiles.has(f) ? 1 : 0,
156 risk: (loopCountMap[f] || 0) * 3 + (ruleCountMap[f] || 0) * 1 + (exportFiles.has(f) ? 2 : 0)
157 }))
158 .filter(f => f.risk > 3)
159 .sort((a, b) => b.risk - a.risk)
160
161 for (const f of riskScores.slice(0, 15)) {
162 console.log(` risk=${String(f.risk).padStart(3)} ${f.file} (${f.loops} loops, ${f.rules} rules${f.exports ? ', exports API' : ''})`)
163 }
164
165 // ── 7. Key insight ──
166 console.log('\n── 7. Key Insights ──')
167 const totalLoopEdges = parseInt(await evalScheme('(length (cog-incoming-by-type (BondNode "loop-in-file") (quote EdgeLink)))')) || 0
168 const totalRuleEdges = parseInt(await evalScheme('(length (cog-incoming-by-type (BondNode "rule-for-file") (quote EdgeLink)))')) || 0
169 const totalExportEdges = parseInt(await evalScheme('(length (cog-incoming-by-type (BondNode "exports") (quote EdgeLink)))')) || 0
170
171 console.log(` Graph: ${totalLoopEdges} loop edges, ${totalRuleEdges} rule edges, ${totalExportEdges} export edges`)
172 console.log(` Module boundary corrections: ${loopsAtBoundary}/${loopFiles.size} (${(loopsAtBoundary / loopFiles.size * 100).toFixed(0)}%)`)
173 console.log(` Public API files with problems: ${exportWithRules.length}/${exportFiles.size}`)
174
175 const topType = Object.entries(typeCounts).sort((a, b) => b[1] - a[1])[0]
176 if (topType) console.log(` Most common loop type: ${topType[0]} (${topType[1]} occurrences)`)
177
178 const worstMod = modDensity[0]
179 if (worstMod) console.log(` Worst module by density: ${worstMod.mod} (${(worstMod.density * 100).toFixed(0)}% of files have loops)`)
180}
181
182main().catch(err => {
183 console.error('Analysis failed:', err.message)
184 process.exit(1)
185})
atomspace/tool-watcher.js +181 -0
……@@ -0,0 +1,181 @@
1#!/usr/bin/env node
2
3/**
4 * tool-watcher.js — Proactive PostToolUse intervention hook
5 *
6 * Watches for behavioral patterns DURING execution (not after edit):
7 * - Same tool + similar args N times (redundant retry)
8 * - Edit/Write on a file that was never Read
9 * - Rapid Bash attempts (thrashing)
10 * - Repetitive Grep patterns (not finding what we need)
11 * - Too many tool calls with no edits (fumbling)
12 *
13 * Writes advice to stderr (async, non-blocking). Claude sees the advice
14 * via hook output, user sees it in the session log.
15 *
16 * State kept in /tmp/claude-tool-watcher-state.json so we can track tool
17 * usage across turns.
18 */
19
20const fs = require('fs')
21
22const STATE_FILE = '/tmp/claude-tool-watcher-state.json'
23const SESSION_FILE = '/tmp/claude-atomspace-session'
24const MAX_STATE_TOOLS = 30 // keep last 30 tool calls
25
26function readState () {
27 try { return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) }
28 catch { return { tools: [], readFiles: new Set(), session: null } }
29}
30
31function writeState (state) {
32 const serializable = {
33 tools: state.tools,
34 readFiles: [...(state.readFiles || [])], // convert Set to array
35 session: state.session,
36 lastTurn: state.lastTurn
37 }
38 try { fs.writeFileSync(STATE_FILE, JSON.stringify(serializable)) } catch {}
39}
40
41function loadSessionId () {
42 try { return fs.readFileSync(SESSION_FILE, 'utf8').trim() } catch { return null }
43}
44
45/**
46 * Classify a tool call into a category and extract key args for similarity check.
47 */
48function summarize (toolName, toolInput) {
49 const input = toolInput || {}
50 switch (toolName) {
51 case 'Read':
52 return { kind: 'read', target: input.file_path || '', full: `${toolName}:${input.file_path}` }
53 case 'Edit':
54 return { kind: 'edit', target: input.file_path || '', full: `${toolName}:${input.file_path}:${(input.old_string || '').slice(0, 40)}` }
55 case 'Write':
56 return { kind: 'write', target: input.file_path || '', full: `${toolName}:${input.file_path}` }
57 case 'Bash':
58 return { kind: 'bash', target: (input.command || '').slice(0, 80), full: `Bash:${(input.command || '').slice(0, 80)}` }
59 case 'Grep':
60 return { kind: 'grep', target: input.pattern || '', full: `Grep:${input.pattern}:${input.path || ''}` }
61 case 'Glob':
62 return { kind: 'glob', target: input.pattern || '', full: `Glob:${input.pattern}` }
63 default:
64 return { kind: 'other', target: toolName, full: toolName }
65 }
66}
67
68/**
69 * Detect patterns from the recent tool history.
70 * Returns array of { severity, message } advice items.
71 */
72function detectPatterns (state, currentTool) {
73 const advice = []
74 const recent = state.tools.slice(-10)
75
76 // Pattern 1: Same tool+args 3+ times in last 5 calls
77 const last5 = state.tools.slice(-5)
78 const sameAsLast = last5.filter(t => t.full === currentTool.full).length
79 if (sameAsLast >= 3) {
80 advice.push({
81 severity: 'high',
82 message: `Same tool call 3+ times (${currentTool.kind}: ${currentTool.target.slice(0, 60)}). Try a different approach.`
83 })
84 }
85
86 // Pattern 2: Repeated Grep on same term with nothing found
87 if (currentTool.kind === 'grep') {
88 const recentGreps = recent.filter(t => t.kind === 'grep')
89 if (recentGreps.length >= 4) {
90 advice.push({
91 severity: 'med',
92 message: `4+ Greps recently. Consider reading the file directly instead of pattern-matching.`
93 })
94 }
95 }
96
97 // Pattern 3: Edit/Write without prior Read on that file
98 if (currentTool.kind === 'edit' || currentTool.kind === 'write') {
99 const hasRead = (state.readFiles || []).includes(currentTool.target)
100 if (!hasRead) {
101 advice.push({
102 severity: 'high',
103 message: `Editing ${currentTool.target.split('/').pop()} without having Read it first. Read the full file before editing.`
104 })
105 }
106 }
107
108 // Pattern 4: 5+ Bash calls with no Edit/Write in between (thrashing)
109 const recentBashCount = recent.filter(t => t.kind === 'bash').length
110 const recentEditCount = recent.filter(t => t.kind === 'edit' || t.kind === 'write').length
111 if (recentBashCount >= 5 && recentEditCount === 0) {
112 advice.push({
113 severity: 'med',
114 message: `${recentBashCount} Bash calls recently with no edits. Debugging loop? Consider stopping to think.`
115 })
116 }
117
118 // Pattern 5: 10+ tool calls since last correction without an edit
119 // (just churning without producing output)
120 const callsSinceEdit = state.tools.length - state.tools.findLastIndex(t => t.kind === 'edit' || t.kind === 'write')
121 if (callsSinceEdit > 12 && state.tools.length > 15) {
122 advice.push({
123 severity: 'low',
124 message: `${callsSinceEdit} tool calls since last edit. Narrow the scope or make a concrete change.`
125 })
126 }
127
128 return advice
129}
130
131async function main () {
132 // Read hook input
133 let input = ''
134 for await (const chunk of process.stdin) input += chunk
135
136 let data
137 try { data = JSON.parse(input) } catch { process.exit(0) }
138
139 const toolName = data.tool_name || data.tool
140 if (!toolName) process.exit(0)
141
142 // Reset state if new session
143 const sessionId = loadSessionId()
144 let state = readState()
145 if (state.session !== sessionId) {
146 state = { tools: [], readFiles: [], session: sessionId, lastTurn: 0 }
147 }
148 // Convert readFiles back to array (we serialized as array)
149 if (!Array.isArray(state.readFiles)) state.readFiles = []
150
151 const toolSummary = summarize(toolName, data.tool_input)
152
153 // Update state
154 state.tools.push(toolSummary)
155 if (state.tools.length > MAX_STATE_TOOLS) {
156 state.tools = state.tools.slice(-MAX_STATE_TOOLS)
157 }
158 if (toolSummary.kind === 'read' && toolSummary.target) {
159 if (!state.readFiles.includes(toolSummary.target)) state.readFiles.push(toolSummary.target)
160 }
161
162 writeState(state)
163
164 // Detect patterns
165 const advice = detectPatterns(state, toolSummary)
166 if (advice.length === 0) process.exit(0)
167
168 // Cap output to avoid noise — top severity only
169 const sevOrder = { high: 0, med: 1, low: 2 }
170 advice.sort((a, b) => sevOrder[a.severity] - sevOrder[b.severity])
171 const top = advice[0]
172
173 // Write to stderr (non-blocking guidance)
174 // Claude Code hooks surface stderr as a system message
175 const marker = top.severity === 'high' ? '‼' : top.severity === 'med' ? '⚠' : '·'
176 process.stderr.write(`[tool-watcher] ${marker} ${top.message}\n`)
177
178 process.exit(0)
179}
180
181main().catch(() => process.exit(0))
atomspace/wisdom-score.js +292 -0
……@@ -0,0 +1,292 @@
1#!/usr/bin/env node
2
3/**
4 * wisdom-score.js — Compute and store wisdom scores for rules and meta atoms
5 *
6 * Three measurable dimensions:
7 * 1. referent_health — % of files referenced that still exist
8 * 2. breadth — how many distinct contexts the rule applies to
9 * 3. utility — prevented_fires / (prevented + contradicted)
10 *
11 * Composite: wisdom = utility × referent_health × log(1 + breadth)
12 *
13 * Storage (as FloatValues on each rule/meta atom):
14 * (Predicate "wisdom") composite score 0..1+
15 * (Predicate "referent-health") 0..1
16 * (Predicate "breadth") integer count
17 * (Predicate "utility") 0..1
18 * (Predicate "utility-fires") times this rule fired
19 * (Predicate "utility-contradictions") times a correction followed a firing
20 *
21 * NO ATOMS ARE DELETED. This only ranks.
22 *
23 * Usage:
24 * node atomspace/wisdom-score.js # compute + store
25 * node atomspace/wisdom-score.js --report # just print, don't store
26 */
27
28const fs = require('fs')
29const path = require('path')
30const { evalScheme, isCogServerUp } = require('./cogserver')
31
32const HYPERCORE_KB = path.resolve(__dirname, '../..')
33const HYPER_FLEEKY = path.join(HYPERCORE_KB, 'hyper-fleeky')
34
35function esc (s) {
36 return String(s || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"')
37}
38
39/**
40 * Parse a scheme result listing atom names.
41 */
42function parseAtomNames (resultStr) {
43 if (!resultStr) return []
44 const names = []
45 const re = /(?:ConceptNode|Concept) "([^"]+)"/g
46 let m
47 while ((m = re.exec(resultStr)) !== null) names.push(m[1])
48 return names
49}
50
51/**
52 * Check if a file exists at its real filesystem location.
53 * Paths in AtomSpace are normalized — reconstruct the full path.
54 */
55function fileExists (normalPath) {
56 if (!normalPath) return false
57 // Skip non-file paths (memory notes, plans, external)
58 if (normalPath.startsWith('/home/') || normalPath.startsWith('/tmp/')) {
59 // Memory files — check existence at actual path
60 try { return fs.existsSync(normalPath) } catch { return false }
61 }
62 const candidates = [
63 normalPath, // already absolute
64 path.join(HYPER_FLEEKY, normalPath),
65 path.join(HYPERCORE_KB, normalPath),
66 path.join('/storage/projects/code', normalPath),
67 path.join('/storage/projects/code/godot', normalPath) // Godot projects live here
68 ]
69 for (const p of candidates) {
70 try { if (fs.existsSync(p)) return true } catch {}
71 }
72 // For Godot paths with module/<file>.cpp pattern, try module/src/<file>.cpp
73 // (ratchet's project-mapper sometimes strips the src/ prefix)
74 const parts = normalPath.split('/')
75 if (parts.length === 2) {
76 const withSrc = path.join('/storage/projects/code/godot', parts[0], 'src', parts[1])
77 try { if (fs.existsSync(withSrc)) return true } catch {}
78 }
79 return false
80}
81
82/**
83 * For each rule/meta atom, get its referenced files.
84 * Rules link via 'rule-for-file'. Meta atoms link via 'approach-works',
85 * 'approach-fails', 'try-first', 'avoid-first'.
86 */
87async function getFilesForAtom (atomName, bondTypes) {
88 const files = new Set()
89 for (const bond of bondTypes) {
90 const result = await evalScheme(
91 `(cog-execute! (Meet (TypedVariable (Variable "$f") (Type (quote ConceptNode))) (Present (EdgeLink (BondNode "${bond}") (ListLink (ConceptNode "${esc(atomName)}") (Variable "$f"))))))`)
92 for (const name of parseAtomNames(result)) {
93 if (name.startsWith('file:')) files.add(name.slice(5))
94 }
95 }
96 return [...files]
97}
98
99/**
100 * Breadth: how many distinct contexts this rule applies to.
101 * For meta:loop: atoms — always 1 (single loop).
102 * For meta:pattern: atoms — read loop-count Value.
103 * For rule: atoms — count distinct files referenced (proxy for scope).
104 */
105async function computeBreadth (atomName) {
106 if (atomName.startsWith('meta:pattern:')) {
107 const lc = await evalScheme(
108 `(let ((v (cog-value (ConceptNode "${esc(atomName)}") (Predicate "loop-count")))) (if v (cog-value-ref v 0) 1))`)
109 return Math.max(1, parseFloat(lc) || 1)
110 }
111 return 1
112}
113
114/**
115 * Compute wisdom for all rule and meta atoms.
116 */
117async function computeAll (report = false) {
118 if (!await isCogServerUp()) {
119 console.log('CogServer not running')
120 process.exit(1)
121 }
122
123 // Collect all rule: and meta: atoms
124 const allAtomsStr = await evalScheme(
125 '(string-join (map cog-name (cog-get-atoms (quote ConceptNode))) "|")')
126 const allAtoms = (allAtomsStr || '').replace(/^"|"$/g, '').split('|')
127 const ruleAtoms = allAtoms.filter(n => n.startsWith('rule:'))
128 const metaAtoms = allAtoms.filter(n => n.startsWith('meta:'))
129
130 console.log(`Scoring ${ruleAtoms.length} rule atoms + ${metaAtoms.length} meta atoms`)
131
132 const results = []
133 const batch = []
134
135 // Rule atoms: use 'rule-for-file' bond
136 for (const atomName of ruleAtoms) {
137 const files = await getFilesForAtom(atomName, ['rule-for-file'])
138 const existingFiles = files.filter(fileExists)
139 const referent_health = files.length > 0 ? existingFiles.length / files.length : 1.0
140
141 // Utility: read existing values, default to 1.0
142 const firesStr = await evalScheme(
143 `(let ((v (cog-value (ConceptNode "${esc(atomName)}") (Predicate "utility-fires")))) (if v (cog-value-ref v 0) 0))`)
144 const contradStr = await evalScheme(
145 `(let ((v (cog-value (ConceptNode "${esc(atomName)}") (Predicate "utility-contradictions")))) (if v (cog-value-ref v 0) 0))`)
146 const fires = parseFloat(firesStr) || 0
147 const contradictions = parseFloat(contradStr) || 0
148 let utility = (fires + contradictions) === 0
149 ? 1.0 // innocent until proven guilty
150 : Math.max(0.05, (fires - contradictions) / (fires + contradictions) * 0.5 + 0.5)
151
152 // Phase 4.2: Time decay — rules that haven't fired in 30+ days decay
153 // Does NOT delete — just reduces utility until it fires again
154 const lastFiredStr = await evalScheme(
155 `(let ((v (cog-value (ConceptNode "${esc(atomName)}") (Predicate "last-fired-at")))) (if v (cog-value-ref v 0) 0))`)
156 const lastFired = parseFloat(lastFiredStr) || 0
157 if (lastFired > 0) {
158 const daysSinceFire = (Date.now() / 1000 - lastFired) / 86400
159 if (daysSinceFire > 30) {
160 // Gentle decay: -10% utility per 30 days of dormancy, floor 0.3
161 const decayFactor = Math.max(0.3, 1 - ((daysSinceFire - 30) / 30) * 0.1)
162 utility = utility * decayFactor
163 }
164 }
165
166 // Breadth: # files referenced (proxy for scope)
167 const breadth = Math.max(1, files.length)
168
169 const wisdom = utility * referent_health * Math.log(1 + breadth)
170
171 results.push({
172 atom: atomName,
173 kind: 'rule',
174 files: files.length,
175 existing: existingFiles.length,
176 referent_health,
177 breadth,
178 utility,
179 fires,
180 contradictions,
181 lastFiredDaysAgo: lastFired > 0 ? Math.round((Date.now() / 1000 - lastFired) / 86400) : null,
182 wisdom
183 })
184
185 // Queue for batch ingest
186 const node = `(ConceptNode "${esc(atomName)}")`
187 batch.push(`(cog-set-value! ${node} (Predicate "referent-health") (FloatValue ${referent_health}))`)
188 batch.push(`(cog-set-value! ${node} (Predicate "breadth") (FloatValue ${breadth}))`)
189 batch.push(`(cog-set-value! ${node} (Predicate "utility") (FloatValue ${utility}))`)
190 batch.push(`(cog-set-value! ${node} (Predicate "wisdom") (FloatValue ${wisdom}))`)
191 }
192
193 // Meta atoms: use 'approach-works' + 'approach-fails' or 'try-first' + 'avoid-first'
194 for (const atomName of metaAtoms) {
195 const bondTypes = atomName.startsWith('meta:pattern:')
196 ? ['try-first', 'avoid-first']
197 : ['approach-works', 'approach-fails']
198 const files = await getFilesForAtom(atomName, bondTypes)
199 const existingFiles = files.filter(fileExists)
200 const referent_health = files.length > 0 ? existingFiles.length / files.length : 1.0
201
202 const firesStr = await evalScheme(
203 `(let ((v (cog-value (ConceptNode "${esc(atomName)}") (Predicate "utility-fires")))) (if v (cog-value-ref v 0) 0))`)
204 const contradStr = await evalScheme(
205 `(let ((v (cog-value (ConceptNode "${esc(atomName)}") (Predicate "utility-contradictions")))) (if v (cog-value-ref v 0) 0))`)
206 const fires = parseFloat(firesStr) || 0
207 const contradictions = parseFloat(contradStr) || 0
208 const utility = (fires + contradictions) === 0
209 ? 1.0
210 : Math.max(0.05, (fires - contradictions) / (fires + contradictions) * 0.5 + 0.5)
211
212 const breadth = await computeBreadth(atomName)
213 const wisdom = utility * referent_health * Math.log(1 + breadth)
214
215 results.push({
216 atom: atomName,
217 kind: atomName.startsWith('meta:pattern:') ? 'pattern' : 'per-loop',
218 files: files.length,
219 existing: existingFiles.length,
220 referent_health,
221 breadth,
222 utility,
223 fires,
224 contradictions,
225 wisdom
226 })
227
228 const node = `(ConceptNode "${esc(atomName)}")`
229 batch.push(`(cog-set-value! ${node} (Predicate "referent-health") (FloatValue ${referent_health}))`)
230 batch.push(`(cog-set-value! ${node} (Predicate "breadth") (FloatValue ${breadth}))`)
231 batch.push(`(cog-set-value! ${node} (Predicate "utility") (FloatValue ${utility}))`)
232 batch.push(`(cog-set-value! ${node} (Predicate "wisdom") (FloatValue ${wisdom}))`)
233 }
234
235 // Report
236 const byWisdom = [...results].sort((a, b) => b.wisdom - a.wisdom)
237
238 console.log('\n── Wisdom Distribution ──')
239 const bins = { high: 0, med: 0, low: 0, zero: 0 }
240 const highThreshold = 1.0 // high-wisdom rules
241 const medThreshold = 0.3
242 for (const r of results) {
243 if (r.wisdom >= highThreshold) bins.high++
244 else if (r.wisdom >= medThreshold) bins.med++
245 else if (r.wisdom > 0) bins.low++
246 else bins.zero++
247 }
248 console.log(` high (wisdom ≥ 1.0): ${bins.high}`)
249 console.log(` med (0.3 ≤ wisdom < 1.0): ${bins.med}`)
250 console.log(` low (0 < wisdom < 0.3): ${bins.low}`)
251 console.log(` zero (wisdom = 0): ${bins.zero}`)
252
253 console.log('\n── Top 10 wisest rules ──')
254 for (const r of byWisdom.slice(0, 10)) {
255 const label = r.atom.length > 60 ? r.atom.slice(0, 60) + '…' : r.atom
256 console.log(` wisdom=${r.wisdom.toFixed(2)} (u=${r.utility.toFixed(2)} rh=${r.referent_health.toFixed(2)} br=${r.breadth}) ${label}`)
257 }
258
259 console.log('\n── Referent health issues (health < 0.5) ──')
260 const broken = results.filter(r => r.referent_health < 0.5 && r.files > 0).slice(0, 10)
261 for (const r of broken) {
262 const label = r.atom.length > 60 ? r.atom.slice(0, 60) + '…' : r.atom
263 console.log(` health=${r.referent_health.toFixed(2)} (${r.existing}/${r.files} files exist) ${label}`)
264 }
265
266 if (report) {
267 console.log('\n(--report mode: not storing)')
268 return { total: results.length, bins }
269 }
270
271 // Store as FloatValues
272 console.log(`\nStoring ${batch.length} wisdom values...`)
273 const BATCH = 50
274 let sent = 0
275 for (let i = 0; i < batch.length; i += BATCH) {
276 const slice = batch.slice(i, i + BATCH)
277 await evalScheme('(begin ' + slice.join(' ') + ')')
278 sent += slice.length
279 }
280 console.log(`Stored: ${sent} values`)
281
282 return { total: results.length, bins, sent }
283}
284
285if (require.main === module) {
286 const report = process.argv.includes('--report')
287 computeAll(report).then(result => {
288 console.log('\n' + JSON.stringify(result, null, 2))
289 }).catch(err => { console.error(err.message); process.exit(1) })
290}
291
292module.exports = { computeAll }