OverviewFilesCommitsIssuesDocsReleasesPeers

knowledge-ratchet / atomspace / fire-log.js

107 lines · javascript ref:
/**
 * fire-log.js — Append-only log of rule firings
 *
 * Every time a hook injects a rule/meta-atom into context, record it here.
 * Enables downstream utility tracking and live dashboard display.
 *
 * Format: JSONL, one fire per line
 *   { timestamp, session, hook, atom, atomType, wisdom, context, trigger }
 */

const fs = require('fs')
const path = require('path')

const LOG_FILE = path.join(__dirname, '..', 'data', 'rule-fires.jsonl')
const MAX_LINES = 10000  // Rotate when exceeded

function logFire (entry) {
  try {
    const record = {
      timestamp: new Date().toISOString(),
      session: entry.session || null,
      hook: entry.hook || 'unknown',
      atom: entry.atom,
      atomType: entry.atomType || inferType(entry.atom),
      wisdom: entry.wisdom != null ? entry.wisdom : null,
      context: entry.context || null,   // file path or prompt excerpt
      trigger: entry.trigger || null    // what caused this fire (file, prompt, etc.)
    }
    fs.appendFileSync(LOG_FILE, JSON.stringify(record) + '\n')

    // Phase 4.2: Update last-fired-at and utility-fires counter on atom
    // Non-blocking, best-effort. Feeds time-decay calculation in wisdom-score.
    if (entry.atom && entry.session) {
      setImmediate(() => {
        try {
          const { evalScheme, isCogServerUp } = require('./cogserver')
          isCogServerUp().then(up => {
            if (!up) return
            const ts = Math.floor(Date.now() / 1000)
            const atomName = entry.atom.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
            evalScheme(
              `(cog-set-value! (ConceptNode "${atomName}") (Predicate "last-fired-at") (FloatValue ${ts}))`
            ).catch(() => {})
            evalScheme(
              `(cog-set-value! (ConceptNode "${atomName}") (Predicate "utility-fires") ` +
              `(FloatValue (+ 1 (let ((v (cog-value (ConceptNode "${atomName}") (Predicate "utility-fires")))) ` +
              `(if v (cog-value-ref v 0) 0)))))`
            ).catch(() => {})
          })
        } catch {}
      })
    }

    // Rotate if too large
    try {
      const stats = fs.statSync(LOG_FILE)
      if (stats.size > 5 * 1024 * 1024) { // >5MB
        const content = fs.readFileSync(LOG_FILE, 'utf8')
        const lines = content.trim().split('\n')
        const keep = lines.slice(-MAX_LINES)
        fs.writeFileSync(LOG_FILE, keep.join('\n') + '\n')
      }
    } catch {}
  } catch {
    // Silent — never block a hook
  }
}

function inferType (atom) {
  if (!atom) return 'unknown'
  if (atom.startsWith('rule:')) return 'rule'
  if (atom.startsWith('meta:pattern:')) return 'pattern'
  if (atom.startsWith('meta:loop:')) return 'meta-loop'
  return 'other'
}

/**
 * Read recent fires (for dashboard/querying).
 */
function readRecent (n = 50) {
  try {
    if (!fs.existsSync(LOG_FILE)) return []
    const content = fs.readFileSync(LOG_FILE, 'utf8')
    const lines = content.trim().split('\n').filter(Boolean)
    return lines.slice(-n).map(l => { try { return JSON.parse(l) } catch { return null } }).filter(Boolean)
  } catch { return [] }
}

/**
 * Read fires within a time window (ms).
 */
function readSince (sinceMs) {
  try {
    if (!fs.existsSync(LOG_FILE)) return []
    const cutoff = Date.now() - sinceMs
    const content = fs.readFileSync(LOG_FILE, 'utf8')
    const lines = content.trim().split('\n').filter(Boolean)
    const parsed = lines.map(l => { try { return JSON.parse(l) } catch { return null } }).filter(Boolean)
    return parsed.filter(p => {
      const t = new Date(p.timestamp).getTime()
      return t >= cutoff
    })
  } catch { return [] }
}

module.exports = { logFire, readRecent, readSince, LOG_FILE }