| … | … | @@ -0,0 +1,65 @@ |
| 1 | # ratchet-brain | |
| 2 | ||
| 3 | Meta-cognitive layer for the knowledge ratchet — codetree structure + ratchet | |
| 4 | correction history loaded into AtomSpace as a live queryable graph. | |
| 5 | ||
| 6 | **Not a general-purpose library.** Ratchet-specific. If we ever need the | |
| 7 | generic pieces (CogServer client, schema helpers, graph viz) in another | |
| 8 | project, extract them then. | |
| 9 | ||
| 10 | ## What it does | |
| 11 | ||
| 12 | 1. **Ingest** — codetree scan + ratchet data (loops, rules, project-maps) into AtomSpace | |
| 13 | 2. **Wisdom scoring** — ranks rules/meta-atoms by evidence × referent_health × breadth | |
| 14 | 3. **Contrast-based rules** — meta-cognition atoms from success/failure diff | |
| 15 | 4. **Attention routing** — on every UserPromptSubmit, inject top-k relevant rules (budget-capped) | |
| 16 | 5. **Live observability** — stream corrections/edits into AtomSpace in real-time | |
| 17 | 6. **Graph visualization** — `http://localhost:9077/graph` — D3 force-directed view | |
| 18 | 7. **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 | |
| 43 | implementation of ReCoN agent control networks. Different codebase, different | |
| 44 | purpose, currently dormant (proof-of-concept, not in production use). | |
| 45 | ||
| 46 | Both use CogServer and the same EdgeLink/BondNode schema conventions. | |
| 47 | They coexist in the same AtomSpace but do not depend on each other. | |
| 48 | ||
| 49 | ## Principles | |
| 50 | ||
| 51 | 1. **Never delete atoms** — staleness is a Value, not a fate | |
| 52 | 2. **Confidence is graduated** — utility/referent_health/breadth compose | |
| 53 | 3. **Live observability over batch** — stream events in real-time | |
| 54 | 4. **Token budget everywhere** — briefings are capped to prevent context bloat | |
| 55 | 5. **Graceful fallback** — everything degrades to JSON when CogServer is down | |
| 56 | ||
| 57 | ## Status | |
| 58 | ||
| 59 | Live and integrated into knowledge-ratchet hooks (SessionStart, UserPromptSubmit, | |
| 60 | PreToolUse, 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 |
| … | … | @@ -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 | ||
| 15 | const fs = require('fs') | |
| 16 | const path = require('path') | |
| 17 | ||
| 18 | const DATA_DIR = path.join(__dirname, '..', 'data') | |
| 19 | ||
| 20 | function 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 | */ | |
| 29 | function 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 | */ | |
| 56 | function 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 | */ | |
| 94 | function 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 | */ | |
| 128 | function 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 | */ | |
| 164 | function 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 | ||
| 195 | async 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 | ||
| 307 | if (require.main === module) { | |
| 308 | main().catch(err => { console.error(err.message); process.exit(1) }) | |
| 309 | } | |
| 310 | ||
| 311 | module.exports = { timeToCorrection, perDomainScore, contextSaturation, planAdherence } |
| … | … | @@ -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 | ||
| 18 | const fs = require('fs') | |
| 19 | const path = require('path') | |
| 20 | const { isCogServerUp, evalScheme } = require('./cogserver') | |
| 21 | const { relevantAtoms, liveSessionContext } = require('./query') | |
| 22 | const { logFire } = require('./fire-log') | |
| 23 | const { evaluateNow: evalStrategicRules } = require('./strategic-rules') | |
| 24 | ||
| 25 | const SESSION_FILE = '/tmp/claude-atomspace-session' | |
| 26 | const SESSION_STATE_FILE = '/tmp/claude-session-score.json' | |
| 27 | const TOKEN_BUDGET = 500 // hard cap on injected context size | |
| 28 | ||
| 29 | function 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 | */ | |
| 38 | function 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 | */ | |
| 49 | function 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 | */ | |
| 61 | function 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 | */ | |
| 73 | function 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 | ||
| 133 | async 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 | ||
| 238 | main().catch(() => process.exit(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 | ||
| 18 | const fs = require('fs') | |
| 19 | const path = require('path') | |
| 20 | ||
| 21 | const DATA_DIR = path.join(__dirname, '..', 'data') | |
| 22 | ||
| 23 | function 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 | */ | |
| 33 | function 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 | */ | |
| 73 | function 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 | ||
| 177 | function 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 | ||
| 190 | function 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 | ||
| 198 | async 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 | ||
| 283 | if (require.main === module) { | |
| 284 | main().catch(err => { console.error(err.message); process.exit(1) }) | |
| 285 | } | |
| 286 | ||
| 287 | module.exports = { buildChains, analyzeChain } |
| … | … | @@ -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 | ||
| 10 | const { evalScheme, isCogServerUp } = require('./cogserver') | |
| 11 | ||
| 12 | function 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 | ||
| 50 | function esc (s) { | |
| 51 | return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') | |
| 52 | } | |
| 53 | ||
| 54 | async 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 | ||
| 150 | main().catch(err => { console.error(err.message); process.exit(1) }) |
| … | … | @@ -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 | ||
| 11 | const net = require('net') | |
| 12 | ||
| 13 | const DEFAULT_PORT = 17001 | |
| 14 | const DEFAULT_HOST = 'localhost' | |
| 15 | const PROMPT_RE = /guile> / // matches inside ANSI-colored output too | |
| 16 | const OPENCOG_PROMPT_RE = /opencog> / | |
| 17 | const ANSI_RE = /\x1b\[[0-9;]*m/g // strip ANSI color codes | |
| 18 | const CONNECT_TIMEOUT = 2000 | |
| 19 | const EVAL_TIMEOUT = 10000 | |
| 20 | ||
| 21 | function 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 | */ | |
| 29 | async 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 | */ | |
| 49 | async 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 | */ | |
| 104 | async 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 | */ | |
| 117 | async function evalSchemeVoid (code, port = DEFAULT_PORT, host = DEFAULT_HOST) { | |
| 118 | const result = await evalScheme(code, port, host) | |
| 119 | return result !== null | |
| 120 | } | |
| 121 | ||
| 122 | module.exports = { | |
| 123 | isCogServerUp, | |
| 124 | evalScheme, | |
| 125 | evalSchemeBatch, | |
| 126 | evalSchemeVoid, | |
| 127 | DEFAULT_PORT, | |
| 128 | DEFAULT_HOST | |
| 129 | } |
| … | … | @@ -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 | ||
| 21 | const fs = require('fs') | |
| 22 | const path = require('path') | |
| 23 | const { evalScheme, isCogServerUp } = require('./cogserver') | |
| 24 | const S = require('./schema') | |
| 25 | ||
| 26 | const LOOPS_FILE = path.join(__dirname, '..', 'data', 'loops.json') | |
| 27 | ||
| 28 | function 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 | */ | |
| 36 | function 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 | */ | |
| 47 | function 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 | */ | |
| 62 | function 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 | */ | |
| 100 | function 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 | */ | |
| 166 | async 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 | ||
| 255 | async 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 | ||
| 275 | main().catch(err => { console.error(err.message); process.exit(1) }) |
| … | … | @@ -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 | ||
| 11 | const fs = require('fs') | |
| 12 | const path = require('path') | |
| 13 | ||
| 14 | const LOG_FILE = path.join(__dirname, '..', 'data', 'rule-fires.jsonl') | |
| 15 | const MAX_LINES = 10000 // Rotate when exceeded | |
| 16 | ||
| 17 | function 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 | ||
| 69 | function 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 | */ | |
| 80 | function 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 | */ | |
| 92 | function 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 | ||
| 106 | module.exports = { logFire, readRecent, readSince, LOG_FILE } |
| … | … | @@ -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 | ||
| 26 | const { evalScheme, isCogServerUp } = require('./cogserver') | |
| 27 | ||
| 28 | // Node type classification by prefix | |
| 29 | const 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 | ||
| 44 | function 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 | ||
| 51 | function 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 | */ | |
| 63 | async 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 | */ | |
| 72 | async 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 | */ | |
| 87 | async 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 | */ | |
| 122 | async 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 | */ | |
| 181 | async 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 | */ | |
| 212 | async 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 | |
| 265 | if (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 | ||
| 278 | module.exports = { exportGraph } |
| … | … | @@ -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> | |
| 124 | const 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 | ||
| 140 | const 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 | ||
| 154 | const svg = d3.select('#graph') | |
| 155 | const tooltip = document.getElementById('tooltip') | |
| 156 | let simulation = null | |
| 157 | ||
| 158 | function 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 | ||
| 169 | function 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 | ||
| 183 | function 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 | ||
| 197 | function 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 | ||
| 272 | function 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 | ||
| 280 | async 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 | ||
| 299 | renderLegend() | |
| 300 | loadGraph() | |
| 301 | ||
| 302 | // ── Live rule firings — polling every 3 seconds ── | |
| 303 | let firingAtoms = new Set() // atoms that fired recently | |
| 304 | let lastSeenTimestamp = 0 | |
| 305 | ||
| 306 | const PULSE_WINDOW_MS = 300000 // 5 minutes — atoms fired within this window pulse | |
| 307 | let lastFireTimestamp = null | |
| 308 | ||
| 309 | async 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 | ||
| 380 | setInterval(pollFires, 3000) | |
| 381 | pollFires() | |
| 382 | </script> | |
| 383 | </body> | |
| 384 | </html> |
| … | … | @@ -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 | ||
| 29 | const fs = require('fs') | |
| 30 | const path = require('path') | |
| 31 | ||
| 32 | const FIRES_FILE = path.join(__dirname, '..', 'data', 'rule-fires.jsonl') | |
| 33 | const CORRECTIONS_LOG = path.join(__dirname, '..', 'corrections.log') | |
| 34 | const SESSION_STATE_FILE = '/tmp/claude-session-score.json' | |
| 35 | ||
| 36 | const DEFAULT_WINDOW_SECONDS = 600 // 10 minutes default | |
| 37 | ||
| 38 | function 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 | */ | |
| 54 | function 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 | */ | |
| 77 | function 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 | */ | |
| 98 | function 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 | ||
| 128 | function 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 | ||
| 195 | if (require.main === module) { | |
| 196 | main() | |
| 197 | } | |
| 198 | ||
| 199 | module.exports = { readFires, readCorrections, analyzeFires, aggregate } |
| … | … | @@ -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 | ||
| 15 | const { execSync } = require('child_process') | |
| 16 | const path = require('path') | |
| 17 | const fs = require('fs') | |
| 18 | const { evalScheme, evalSchemeVoid, isCogServerUp } = require('./cogserver') | |
| 19 | const S = require('./schema') | |
| 20 | ||
| 21 | const CODETREE_BIN = path.resolve(__dirname, '../../hyper-fleeky/codetree/index.js') | |
| 22 | const CACHE_FILE = path.resolve(__dirname, '../data/codetree-cache.json') | |
| 23 | const 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 | */ | |
| 29 | function 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 | */ | |
| 59 | function 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 | */ | |
| 166 | function 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 | */ | |
| 178 | async 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 | |
| 213 | if (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 | ||
| 221 | module.exports = { ingestCodetree, scanCodetree, treeToScheme } |
| … | … | @@ -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 | ||
| 14 | const path = require('path') | |
| 15 | const fs = require('fs') | |
| 16 | const { evalScheme, isCogServerUp } = require('./cogserver') | |
| 17 | const S = require('./schema') | |
| 18 | ||
| 19 | const DATA_DIR = path.resolve(__dirname, '../data') | |
| 20 | ||
| 21 | function 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 | */ | |
| 30 | function 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 | */ | |
| 77 | function 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 | */ | |
| 126 | function 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 | */ | |
| 164 | function 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 | */ | |
| 183 | async 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 | |
| 236 | if (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 | ||
| 243 | module.exports = { ingestRatchet, loopsToScheme, rulesToScheme, projectMapsToScheme } |
| … | … | @@ -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 | ||
| 11 | const { isCogServerUp, evalScheme } = require('./cogserver') | |
| 12 | const { ingestCodetree } = require('./ingest-codetree') | |
| 13 | const { ingestRatchet } = require('./ingest-ratchet') | |
| 14 | const { execSync } = require('child_process') | |
| 15 | const path = require('path') | |
| 16 | ||
| 17 | async 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 | */ | |
| 144 | async 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 | ||
| 178 | if (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 | ||
| 186 | module.exports = { ingestAll } |
| … | … | @@ -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 | ||
| 16 | const fs = require('fs') | |
| 17 | const path = require('path') | |
| 18 | const { evalScheme, isCogServerUp } = require('./cogserver') | |
| 19 | ||
| 20 | const DATA_DIR = path.join(__dirname, '..', 'data') | |
| 21 | const FIRES_FILE = path.join(DATA_DIR, 'rule-fires.jsonl') | |
| 22 | const CORRECTIONS_FILE = path.join(DATA_DIR, '..', 'corrections.log') | |
| 23 | const UTILITY_WINDOW_SEC = 600 // 10 min after fire to detect "followed by correction" | |
| 24 | ||
| 25 | function 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 | ||
| 36 | function 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 | ||
| 44 | function 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 | ||
| 58 | async 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 | ||
| 143 | if (require.main === module) { | |
| 144 | main().catch(err => { console.error(err.message); process.exit(1) }) | |
| 145 | } | |
| 146 | ||
| 147 | module.exports = { classifyAtomType } |
| … | … | @@ -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 | ||
| 12 | const { evalScheme, isCogServerUp } = require('./cogserver') | |
| 13 | const 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 | */ | |
| 20 | function 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 | */ | |
| 35 | function 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 | */ | |
| 53 | async 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 | */ | |
| 96 | async 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 | */ | |
| 112 | async 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 | */ | |
| 171 | async 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 | */ | |
| 231 | function 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 | */ | |
| 252 | async 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 | */ | |
| 292 | async 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 | */ | |
| 353 | async 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 | */ | |
| 379 | async 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 | */ | |
| 412 | async 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 | */ | |
| 513 | async 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 | */ | |
| 593 | async 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 | */ | |
| 708 | async 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 | ||
| 743 | module.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 | } |
| … | … | @@ -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 | ||
| 17 | const fs = require('fs') | |
| 18 | const path = require('path') | |
| 19 | const readline = require('readline') | |
| 20 | ||
| 21 | const DATA_DIR = path.join(__dirname, '..', 'data') | |
| 22 | const CANDIDATES_FILE = path.join(DATA_DIR, 'rule-candidates.jsonl') | |
| 23 | const LEARNED_RULES_FILE = path.join(DATA_DIR, 'learned-rules.json') | |
| 24 | const ARCHIVE_FILE = path.join(DATA_DIR, 'rule-candidates-archive.jsonl') | |
| 25 | ||
| 26 | function 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 | ||
| 34 | function writeCandidates (candidates) { | |
| 35 | fs.writeFileSync(CANDIDATES_FILE, candidates.map(c => JSON.stringify(c)).join('\n') + (candidates.length > 0 ? '\n' : '')) | |
| 36 | } | |
| 37 | ||
| 38 | function archive (candidate) { | |
| 39 | fs.appendFileSync(ARCHIVE_FILE, JSON.stringify(candidate) + '\n') | |
| 40 | } | |
| 41 | ||
| 42 | function 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 | ||
| 50 | async 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 | ||
| 143 | if (require.main === module) { | |
| 144 | main().catch(err => { console.error(err.message); process.exit(1) }) | |
| 145 | } |
| … | … | @@ -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) | |
| 25 | function 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 | ||
| 35 | function modNode (name) { | |
| 36 | return `(ConceptNode "mod:${esc(name)}")` | |
| 37 | } | |
| 38 | ||
| 39 | function fileNode (path) { | |
| 40 | return `(ConceptNode "file:${esc(path)}")` | |
| 41 | } | |
| 42 | ||
| 43 | function fnNode (name) { | |
| 44 | return `(ConceptNode "fn:${esc(name)}")` | |
| 45 | } | |
| 46 | ||
| 47 | function loopNode (fingerprint) { | |
| 48 | return `(ConceptNode "loop:${esc(fingerprint)}")` | |
| 49 | } | |
| 50 | ||
| 51 | function ruleNode (id) { | |
| 52 | return `(ConceptNode "rule:${esc(id)}")` | |
| 53 | } | |
| 54 | ||
| 55 | // --- Edge builder --- | |
| 56 | ||
| 57 | function edge (bondType, src, dst) { | |
| 58 | return `(EdgeLink (BondNode "${esc(bondType)}") (ListLink ${src} ${dst}))` | |
| 59 | } | |
| 60 | ||
| 61 | // --- Value setters --- | |
| 62 | ||
| 63 | function setStringValue (node, key, value) { | |
| 64 | return `(cog-set-value! ${node} (Predicate "${esc(key)}") (StringValue "${esc(value)}"))` | |
| 65 | } | |
| 66 | ||
| 67 | function setFloatValue (node, key, value) { | |
| 68 | return `(cog-set-value! ${node} (Predicate "${esc(key)}") (FloatValue ${value}))` | |
| 69 | } | |
| 70 | ||
| 71 | // --- Query builders --- | |
| 72 | ||
| 73 | function 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 | |
| 78 | function 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 | |
| 85 | function 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 | |
| 92 | function 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 | |
| 99 | function 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 | |
| 106 | function 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 | |
| 113 | function 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 | |
| 118 | function 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 | |
| 123 | function reportCounts () { | |
| 124 | return '(cog-report-counts)' | |
| 125 | } | |
| 126 | ||
| 127 | module.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 | } |
| … | … | @@ -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" | |
| 20 | const 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 | */ | |
| 79 | function 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 | */ | |
| 92 | function 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 | */ | |
| 106 | function 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 | */ | |
| 120 | function 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 | */ | |
| 145 | function 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 | |
| 187 | if (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 | ||
| 227 | module.exports = { classifyIntent, ngramFingerprint, ngramSimilarity, isSemanticRepeat, analyzeCorrections } |
| … | … | @@ -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 | ||
| 21 | const fs = require('fs') | |
| 22 | const path = require('path') | |
| 23 | const { isCogServerUp, evalScheme } = require('./cogserver') | |
| 24 | ||
| 25 | const DATA_DIR = path.join(__dirname, '..', 'data') | |
| 26 | const FUSED_FILE = path.join(DATA_DIR, 'fused-scores.json') | |
| 27 | const LOOPS_FILE = path.join(DATA_DIR, 'loops.json') | |
| 28 | ||
| 29 | function 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 | */ | |
| 37 | function 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 | */ | |
| 65 | function 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 | */ | |
| 144 | function 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 | */ | |
| 175 | function 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 | */ | |
| 189 | function 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 | */ | |
| 212 | function 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 | ||
| 252 | async 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 | ||
| 363 | if (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 | ||
| 368 | module.exports = { inferTopics, inferOutcome, findContinuationOf } |
| … | … | @@ -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 | ||
| 11 | const { execSync, spawn } = require('child_process') | |
| 12 | const { isCogServerUp, evalScheme } = require('./cogserver') | |
| 13 | const { ingestAll } = require('./ingest') | |
| 14 | ||
| 15 | const COGSERVER_PORT = 17001 | |
| 16 | const STARTUP_WAIT = 2000 | |
| 17 | ||
| 18 | async 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 | */ | |
| 54 | async 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 | ||
| 77 | async 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 | */ | |
| 120 | async 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 | ||
| 186 | main().catch(() => process.exit(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 | ||
| 15 | const fs = require('fs') | |
| 16 | ||
| 17 | const STATE_FILE = '/tmp/claude-session-score.json' | |
| 18 | ||
| 19 | /** | |
| 20 | * Read live session state. | |
| 21 | */ | |
| 22 | function 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 | */ | |
| 34 | const 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 | */ | |
| 121 | function 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 | */ | |
| 140 | function evaluateNow () { | |
| 141 | const state = readSessionState() | |
| 142 | return evaluateRules(state) | |
| 143 | } | |
| 144 | ||
| 145 | // CLI — check live session and print firing rules | |
| 146 | if (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 | ||
| 159 | module.exports = { STRATEGIC_RULES, evaluateRules, evaluateNow } |
| … | … | @@ -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 | ||
| 15 | const fs = require('fs') | |
| 16 | const path = require('path') | |
| 17 | const { isCogServerUp, evalScheme } = require('./cogserver') | |
| 18 | ||
| 19 | const SESSION_FILE = '/tmp/claude-atomspace-session' | |
| 20 | const SESSION_STATE_FILE = '/tmp/claude-session-score.json' | |
| 21 | ||
| 22 | const 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 | ||
| 30 | const APPROVAL_PATTERNS = [ | |
| 31 | /\b(perfect|exactly|great|yes|lgtm|looks good|nice|thanks)\b/i | |
| 32 | ] | |
| 33 | ||
| 34 | function esc (s) { | |
| 35 | return String(s || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, ' ') | |
| 36 | } | |
| 37 | ||
| 38 | function readSessionId () { | |
| 39 | try { return fs.readFileSync(SESSION_FILE, 'utf8').trim() } catch { return null } | |
| 40 | } | |
| 41 | ||
| 42 | function 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 | ||
| 49 | function 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 | */ | |
| 59 | async 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 | ||
| 68 | async 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 | ||
| 199 | main().catch(() => process.exit(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 | ||
| 11 | const fs = require('fs') | |
| 12 | const { isCogServerUp, evalScheme } = require('./cogserver') | |
| 13 | ||
| 14 | const SESSION_FILE = '/tmp/claude-atomspace-session' | |
| 15 | ||
| 16 | function esc (s) { | |
| 17 | return String(s || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"') | |
| 18 | } | |
| 19 | ||
| 20 | function readSessionId () { | |
| 21 | try { return fs.readFileSync(SESSION_FILE, 'utf8').trim() } catch { return null } | |
| 22 | } | |
| 23 | ||
| 24 | function 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 | ||
| 32 | async 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 | ||
| 83 | main().catch(() => process.exit(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 | ||
| 17 | const fs = require('fs') | |
| 18 | const path = require('path') | |
| 19 | const { evalScheme, isCogServerUp } = require('./cogserver') | |
| 20 | ||
| 21 | const DATA_DIR = path.join(__dirname, '..', 'data') | |
| 22 | const LOOKBACK = 5 // turns before approval to capture | |
| 23 | const MIN_TOOL_CALLS = 3 // need some actual activity to count | |
| 24 | ||
| 25 | function esc (s) { | |
| 26 | return String(s || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, ' ') | |
| 27 | } | |
| 28 | ||
| 29 | function 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 | */ | |
| 42 | function 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 | */ | |
| 112 | function classifySuccess (s) { | |
| 113 | if (s.correctedFirst) return 'resolution' | |
| 114 | return 'validation' | |
| 115 | } | |
| 116 | ||
| 117 | /** | |
| 118 | * Generate Scheme to ingest a success record. | |
| 119 | */ | |
| 120 | function 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 | ||
| 155 | async 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 | ||
| 218 | if (require.main === module) { | |
| 219 | main().catch(err => { console.error(err.message); process.exit(1) }) | |
| 220 | } | |
| 221 | ||
| 222 | module.exports = { extractSuccesses, classifySuccess } |
| … | … | @@ -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 | ||
| 17 | const fs = require('fs') | |
| 18 | const path = require('path') | |
| 19 | const { evalScheme, isCogServerUp } = require('./cogserver') | |
| 20 | const { extractSuccesses, classifySuccess } = require('./success-memory') | |
| 21 | ||
| 22 | const DATA_DIR = path.join(__dirname, '..', 'data') | |
| 23 | const MIN_SUCCESSES = 3 | |
| 24 | ||
| 25 | function esc (s) { | |
| 26 | return String(s || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, ' ') | |
| 27 | } | |
| 28 | ||
| 29 | function 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 | */ | |
| 41 | function 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 | */ | |
| 58 | function 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 | ||
| 96 | function 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 | ||
| 120 | async 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 | ||
| 170 | if (require.main === module) { | |
| 171 | main().catch(err => { console.error(err.message); process.exit(1) }) | |
| 172 | } | |
| 173 | ||
| 174 | module.exports = { groupSuccesses, buildPreferredApproaches } |
| … | … | @@ -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 | ||
| 19 | const { isCogServerUp, evalScheme } = require('../cogserver') | |
| 20 | const { blastRadius, similarLoops, riskAssessment } = require('../query') | |
| 21 | const { ingestCodetree, treeToScheme, scanCodetree } = require('../ingest-codetree') | |
| 22 | const { ingestRatchet } = require('../ingest-ratchet') | |
| 23 | const { execSync } = require('child_process') | |
| 24 | const path = require('path') | |
| 25 | ||
| 26 | async 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 | ||
| 43 | async 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 | ||
| 161 | main().catch(err => { | |
| 162 | console.error('Bench failed:', err.message) | |
| 163 | process.exit(1) | |
| 164 | }) |
| … | … | @@ -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 | ||
| 15 | const { evalScheme, isCogServerUp } = require('../cogserver') | |
| 16 | ||
| 17 | function parseList (str) { | |
| 18 | if (!str) return [] | |
| 19 | return str.replace(/^"|"$/g, '').split('|').filter(Boolean) | |
| 20 | } | |
| 21 | ||
| 22 | async 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 | ||
| 182 | main().catch(err => { | |
| 183 | console.error('Analysis failed:', err.message) | |
| 184 | process.exit(1) | |
| 185 | }) |
| … | … | @@ -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 | ||
| 20 | const fs = require('fs') | |
| 21 | ||
| 22 | const STATE_FILE = '/tmp/claude-tool-watcher-state.json' | |
| 23 | const SESSION_FILE = '/tmp/claude-atomspace-session' | |
| 24 | const MAX_STATE_TOOLS = 30 // keep last 30 tool calls | |
| 25 | ||
| 26 | function readState () { | |
| 27 | try { return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) } | |
| 28 | catch { return { tools: [], readFiles: new Set(), session: null } } | |
| 29 | } | |
| 30 | ||
| 31 | function 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 | ||
| 41 | function 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 | */ | |
| 48 | function 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 | */ | |
| 72 | function 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 | ||
| 131 | async 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 | ||
| 181 | main().catch(() => process.exit(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 | ||
| 28 | const fs = require('fs') | |
| 29 | const path = require('path') | |
| 30 | const { evalScheme, isCogServerUp } = require('./cogserver') | |
| 31 | ||
| 32 | const HYPERCORE_KB = path.resolve(__dirname, '../..') | |
| 33 | const HYPER_FLEEKY = path.join(HYPERCORE_KB, 'hyper-fleeky') | |
| 34 | ||
| 35 | function esc (s) { | |
| 36 | return String(s || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"') | |
| 37 | } | |
| 38 | ||
| 39 | /** | |
| 40 | * Parse a scheme result listing atom names. | |
| 41 | */ | |
| 42 | function 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 | */ | |
| 55 | function 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 | */ | |
| 87 | async 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 | */ | |
| 105 | async 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 | */ | |
| 117 | async 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 | ||
| 285 | if (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 | ||
| 292 | module.exports = { computeAll } |