OverviewFilesCommitsIssuesDocsReleasesPeers

knowledge-ratchet / atomspace / session-categorize.js

369 lines · javascript ref:
#!/usr/bin/env node

/**
 * session-categorize.js — Tag sessions with topics, link continuation chains
 *
 * For each session in fused-scores.json:
 *   1. Infer topics from git branch, dominant files edited, correction themes
 *   2. Create session atom in AtomSpace with rich metadata
 *   3. Link to topic atoms via 'session-about' bond
 *   4. Detect continuation chains (same branch/cwd, close in time, shared files)
 *
 * Topics become queryable long-term memory:
 *   "What sessions contributed to atomspace integration?" → chain traversal
 *   "Last time I worked on pear-git-server, what was learned?" → session query
 *
 * Usage:
 *   node atomspace/session-categorize.js           # run and ingest
 *   node atomspace/session-categorize.js --dry-run # print, don't store
 */

const fs = require('fs')
const path = require('path')
const { isCogServerUp, evalScheme } = require('./cogserver')

const DATA_DIR = path.join(__dirname, '..', 'data')
const FUSED_FILE = path.join(DATA_DIR, 'fused-scores.json')
const LOOPS_FILE = path.join(DATA_DIR, 'loops.json')

function esc (s) {
  return String(s || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"')
}

/**
 * Extract the dominant module from a set of file paths.
 * Returns the module (first path segment) most frequently touched.
 */
function dominantModule (files) {
  if (!files || files.length === 0) return null
  const counts = {}
  for (const f of files) {
    if (!f) continue
    const normalized = f
      .replace(/^\/storage\/projects\/code\/hypercore-kb\/hyper-fleeky\//, '')
      .replace(/^\/storage\/projects\/code\/hypercore-kb\//, '')
      .replace(/^\/storage\/projects\/code\//, '')
    const mod = normalized.split('/')[0]
    if (mod && !mod.startsWith('.') && !mod.startsWith('/home')) {
      counts[mod] = (counts[mod] || 0) + 1
    }
  }
  const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1])
  return sorted.length > 0 ? sorted[0][0] : null
}

/**
 * Infer topics from a session's metadata.
 *
 * Signals (in priority order):
 *   1. FILE-EDIT-CLUSTER — dominant module actually edited (most reliable)
 *   2. Loop file clusters — where did loops happen?
 *   3. CWD project — the intended project (can differ from actual work)
 *   4. Git branch — often misleading (drifts), used as fallback
 *   5. Loop types — struggle patterns
 */
function inferTopics (session, loops, sessionTurnDetail) {
  const topics = new Set()

  // 1. FILE-EDIT-CLUSTER — the most reliable topic signal
  // Collect all files edited in this session
  const sessionLoops = (loops.loops || []).filter(l => l.sessionId === session.sessionId)
  const loopFiles = sessionLoops.flatMap(l => [...(l.filesTried || []), ...((l.resolution || {}).files || [])])
  const turnEdits = sessionTurnDetail ? (sessionTurnDetail.turns || []).flatMap(t => t.editFiles || []) : []
  const allFiles = [...loopFiles, ...turnEdits]

  const domMod = dominantModule(allFiles)
  if (domMod) {
    topics.add(`module:${domMod}`)
  }

  // 2. All modules touched (not just dominant) — gives richer topic set
  const moduleCounts = {}
  for (const f of allFiles) {
    if (!f) continue
    const normalized = f
      .replace(/^\/storage\/projects\/code\/hypercore-kb\/hyper-fleeky\//, '')
      .replace(/^\/storage\/projects\/code\/hypercore-kb\//, '')
      .replace(/^\/storage\/projects\/code\//, '')
    const mod = normalized.split('/')[0]
    if (mod && !mod.startsWith('.') && !mod.startsWith('/home')) {
      moduleCounts[mod] = (moduleCounts[mod] || 0) + 1
    }
  }
  // Add top-3 touched modules (captures multi-module sessions)
  const topModules = Object.entries(moduleCounts).sort((a, b) => b[1] - a[1]).slice(0, 3)
  for (const [mod, count] of topModules) {
    if (count >= 2) topics.add(`module:${mod}`)
  }

  // 3. Project from cwd (fallback if no files edited)
  if (session.cwd) {
    const m = session.cwd.match(/\/hyper-fleeky\/([^/]+)/) ||
              session.cwd.match(/\/godot\/([^/]+)/) ||
              session.cwd.match(/\/hypercore-kb\/([^/]+)/)
    if (m) topics.add(`project:${m[1]}`)
  }

  // 4. Git branch (LOW priority — known to drift)
  // Only add if branch looks specific (not a long-lived branch)
  if (session.gitBranch &&
      session.gitBranch !== 'main' && session.gitBranch !== 'master' &&
      session.gitBranch !== 'hypernote-ui-improvements') {
    const branch = session.gitBranch
      .replace(/^(feature|fix|wip|refactor|chore|feat)\//, '')
    if (branch.length > 2 && branch.length < 40) topics.add(`branch:${branch}`)
  }

  // 5. Intent categorization
  const branch = (session.gitBranch || '').toLowerCase()
  if (/fix|bug|error/.test(branch)) topics.add('intent:bugfix')
  else if (/feat|feature|add|new/.test(branch)) topics.add('intent:feature')
  else if (/refactor|cleanup|rewrite/.test(branch)) topics.add('intent:refactor')

  // 6. Struggle themes from loops
  const typeCounts = {}
  for (const l of sessionLoops) {
    typeCounts[l.type] = (typeCounts[l.type] || 0) + 1
  }
  const domType = Object.entries(typeCounts).sort((a, b) => b[1] - a[1])[0]
  if (domType && domType[1] >= 2) {
    topics.add(`struggle:${domType[0]}`)
  }

  return [...topics]
}

/**
 * Determine session territory: new vs established.
 * Based on how many prior sessions touched the same dominant module.
 *
 * new         — <3 prior sessions on this module (exploring unknown territory)
 * familiar    — 3-10 prior sessions
 * established — >10 prior sessions (well-known territory)
 */
function classifyTerritory (session, allSessions, sessionTopics) {
  // Get this session's dominant module from its topics
  const modTopics = (sessionTopics || []).filter(t => t.startsWith('module:'))
  if (modTopics.length === 0) return 'unknown'

  const domModule = modTopics[0].replace('module:', '')
  const sessionTs = new Date(session.timestamp || session.date).getTime()

  // Count prior sessions that touched this module
  let priorCount = 0
  for (const s of allSessions) {
    if (s.sessionId === session.sessionId) continue
    const sTs = new Date(s.timestamp || s.date).getTime()
    if (sTs >= sessionTs) continue  // only earlier sessions
    if (s.cwd && s.cwd.includes('/' + domModule)) priorCount++
    // Also count same branch as weak signal
    if (s.gitBranch && s.gitBranch === session.gitBranch) priorCount += 0.3
  }

  if (priorCount < 3) return 'new'
  if (priorCount < 10) return 'familiar'
  return 'established'
}

/**
 * Determine session outcome.
 * - completed: resolved loops > unresolved
 * - stuck: more unresolved than resolved
 * - clean: few/no loops, likely productive
 * - aborted: short session, lots of tool calls (possibly abandoned)
 */
function inferOutcome (session, loops) {
  const sessionLoops = (loops.loops || []).filter(l => l.sessionId === session.sessionId)
  const resolved = sessionLoops.filter(l => l.resolved).length
  const unresolved = sessionLoops.length - resolved

  if (sessionLoops.length === 0) return 'clean'
  if (unresolved > resolved) return 'stuck'
  if (resolved > 0 && unresolved === 0) return 'completed'
  return 'mixed'
}

/**
 * Detect continuation: previous session close in time, same branch + cwd.
 */
function findContinuationOf (session, allSessions) {
  const sessionTs = new Date(session.timestamp || session.date).getTime()
  const candidates = allSessions.filter(s =>
    s.sessionId !== session.sessionId &&
    s.gitBranch === session.gitBranch &&
    s.cwd === session.cwd &&
    new Date(s.timestamp || s.date).getTime() < sessionTs
  )
  if (candidates.length === 0) return null

  // Most recent prior session
  candidates.sort((a, b) => new Date(b.timestamp || b.date).getTime() - new Date(a.timestamp || a.date).getTime())
  const prev = candidates[0]
  const gapHours = (sessionTs - new Date(prev.timestamp || prev.date).getTime()) / 1000 / 3600

  // Continuation if same branch+cwd and within 7 days
  if (gapHours <= 24 * 7) return { sessionId: prev.sessionId, gapHours: Math.round(gapHours * 10) / 10 }
  return null
}

/**
 * Build Atomese expressions for a session.
 */
function sessionToScheme (session, topics, outcome, continuationOf, territory) {
  const exprs = []
  const sid = session.sessionId
  const sessionNode = `(ConceptNode "session-archive:${esc(sid)}")`

  exprs.push(sessionNode)
  exprs.push(`(cog-set-value! ${sessionNode} (Predicate "date") (StringValue "${esc(session.date)}"))`)
  exprs.push(`(cog-set-value! ${sessionNode} (Predicate "turns") (FloatValue ${session.turns || 0}))`)
  exprs.push(`(cog-set-value! ${sessionNode} (Predicate "score") (FloatValue ${session.score || 0}))`)
  if (session.perTurnScore != null) {
    exprs.push(`(cog-set-value! ${sessionNode} (Predicate "per-turn-score") (FloatValue ${session.perTurnScore}))`)
  }
  if (session.trustedScore != null) {
    exprs.push(`(cog-set-value! ${sessionNode} (Predicate "trusted-score") (FloatValue ${session.trustedScore}))`)
  }
  exprs.push(`(cog-set-value! ${sessionNode} (Predicate "corrections") (FloatValue ${session.corrections || 0}))`)
  exprs.push(`(cog-set-value! ${sessionNode} (Predicate "approvals") (FloatValue ${session.approvals || 0}))`)
  exprs.push(`(cog-set-value! ${sessionNode} (Predicate "outcome") (StringValue "${outcome}"))`)
  exprs.push(`(cog-set-value! ${sessionNode} (Predicate "territory") (StringValue "${territory || 'unknown'}"))`)
  if (session.gitBranch) {
    exprs.push(`(cog-set-value! ${sessionNode} (Predicate "git-branch") (StringValue "${esc(session.gitBranch)}"))`)
  }

  // Topic edges
  for (const topic of topics) {
    const topicNode = `(ConceptNode "topic:${esc(topic)}")`
    exprs.push(topicNode)
    exprs.push(`(EdgeLink (BondNode "session-about") (ListLink ${sessionNode} ${topicNode}))`)
  }

  // Continuation edge
  if (continuationOf) {
    const prevNode = `(ConceptNode "session-archive:${esc(continuationOf.sessionId)}")`
    exprs.push(`(EdgeLink (BondNode "continues") (ListLink ${sessionNode} ${prevNode}))`)
    exprs.push(`(cog-set-value! ${sessionNode} (Predicate "continues-after-hours") (FloatValue ${continuationOf.gapHours}))`)
  }

  return exprs
}

async function main () {
  const dryRun = process.argv.includes('--dry-run')

  const fused = JSON.parse(fs.readFileSync(FUSED_FILE, 'utf8'))
  const loops = JSON.parse(fs.readFileSync(LOOPS_FILE, 'utf8'))
  const sessions = fused.sessions || []

  // Load turn-level detail for file-edit-cluster topic extraction
  const TURNS_FILE = path.join(DATA_DIR, 'fused-turns-detail.json')
  let turnDetailBySession = {}
  try {
    const detail = JSON.parse(fs.readFileSync(TURNS_FILE, 'utf8'))
    const detailSessions = detail.sessions || detail
    for (const s of detailSessions) turnDetailBySession[s.sessionId] = s
  } catch {}

  console.log(`Categorizing ${sessions.length} sessions...`)

  const categorized = sessions.map(s => {
    const detail = turnDetailBySession[s.sessionId]
    const topics = inferTopics(s, loops, detail)
    const outcome = inferOutcome(s, loops)
    const continuationOf = findContinuationOf(s, sessions)
    const territory = classifyTerritory(s, sessions, topics)
    return { session: s, topics, outcome, continuationOf, territory }
  })

  // Topic frequency
  const topicCounts = {}
  for (const c of categorized) {
    for (const t of c.topics) {
      topicCounts[t] = (topicCounts[t] || 0) + 1
    }
  }

  // Outcome distribution
  const outcomes = {}
  for (const c of categorized) {
    outcomes[c.outcome] = (outcomes[c.outcome] || 0) + 1
  }

  // Continuation stats
  const continuations = categorized.filter(c => c.continuationOf).length

  console.log('')
  console.log('── Topic distribution (top 15) ──')
  const topTopics = Object.entries(topicCounts).sort((a, b) => b[1] - a[1]).slice(0, 15)
  for (const [t, n] of topTopics) {
    console.log(`  ${String(n).padStart(4)} sessions  ${t}`)
  }

  console.log('')
  console.log('── Outcome distribution ──')
  for (const [o, n] of Object.entries(outcomes).sort((a, b) => b[1] - a[1])) {
    console.log(`  ${String(n).padStart(4)} sessions  ${o}`)
  }

  // Territory distribution — new vs established
  const territoryCounts = {}
  const territoryScores = {}
  for (const c of categorized) {
    const t = c.territory || 'unknown'
    territoryCounts[t] = (territoryCounts[t] || 0) + 1
    if (!territoryScores[t]) territoryScores[t] = []
    if (c.session.trustedScore != null) territoryScores[t].push(c.session.trustedScore)
  }
  console.log('')
  console.log('── Territory (where work happened) ──')
  for (const [t, n] of Object.entries(territoryCounts).sort((a, b) => b[1] - a[1])) {
    const scores = territoryScores[t] || []
    const avgScore = scores.length > 0
      ? (scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(3)
      : 'n/a'
    console.log(`  ${String(n).padStart(4)} sessions  ${t.padEnd(14)} avg trusted-score=${avgScore}`)
  }

  console.log('')
  console.log(`── Continuation chains: ${continuations}/${sessions.length} sessions continue a previous one ──`)
  for (const c of categorized.filter(c => c.continuationOf).slice(-5)) {
    console.log(`  ${c.session.date} (${c.session.sessionId.slice(0, 8)}…)  ← after ${c.continuationOf.gapHours}h gap`)
  }

  if (dryRun) {
    console.log('\n(--dry-run: not storing)')
    return { categorized: categorized.length }
  }

  if (!await isCogServerUp()) {
    console.log('\nCogServer not running, skipping ingest')
    return { ok: false }
  }

  // Build and ingest Scheme expressions
  const allExprs = []
  for (const c of categorized) {
    allExprs.push(...sessionToScheme(c.session, c.topics, c.outcome, c.continuationOf, c.territory))
  }

  console.log(`\nIngesting ${allExprs.length} Scheme expressions...`)
  const BATCH = 50
  let sent = 0
  for (let i = 0; i < allExprs.length; i += BATCH) {
    const batch = allExprs.slice(i, i + BATCH)
    await evalScheme('(begin ' + batch.join(' ') + ')')
    sent += batch.length
  }

  console.log(`Stored: ${sent} atoms/edges for ${categorized.length} sessions`)
  return { ok: true, sessions: categorized.length, sent }
}

if (require.main === module) {
  main().then(r => console.log('\n' + JSON.stringify(r || {}, null, 2)))
    .catch(err => { console.error(err.message); process.exit(1) })
}

module.exports = { inferTopics, inferOutcome, findContinuationOf }