#!/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 }