* feat: multi-turn context and conversation arc memory PR 2E - Section 2.9, 2.10: - Add multiTurnContext.ts with turn tracking and state preservation - Add conversationArc.ts with goal/decision/milestone tracking - Wire into query.ts after tool execution - Feature-flags: MULTI_TURN_CONTEXT, CONVERSATION_ARC - Add comprehensive tests (22 passing) * feat(cli): add /knowledge command to manage native memory - Add /knowledge enable <yes|no> to toggle Knowledge Graph learning\n- Add /knowledge clear to reset memory\n- Add persistent knowledgeGraphEnabled setting to global config\n- Integrated user setting into the query execution loop * feat(cli): add /knowledge command (stable local-jsx version) - Resolve conflicts between .ts and .tsx files\n- Align with LocalJSXCommandCall signature\n- Fix onDone and args errors * test(cli): fix knowledge command tests by properly isolating global config * fix(cli): make knowledge command defensive against undefined args and leaky tests * fix(cli): correct data source for entity count and fix test isolation * fix(cli): reinforce knowledge test by explicitly defining property on test config * fix(cli): explicitly define property in test config to avoid undefined in CI * fix(cli): make knowledge tests resistant to global config mocks in CI * chore(memory): surgical improvements from architectural audit - Fix: Implement entity deduplication in Knowledge Graph\n- Fix: Ensure fact extraction from user messages in query loop\n- Fix: Refine regexes for better quality learning (less noise) --------- Co-authored-by: LifeJiggy <Bloomtonjovish@gmail.com>
378 lines
9.6 KiB
TypeScript
378 lines
9.6 KiB
TypeScript
/**
|
|
* Conversation Arc Memory - Production Grade
|
|
*
|
|
* Remembers conversation goals and key decisions.
|
|
* High-level abstraction of conversation progress.
|
|
*/
|
|
|
|
import type { Message } from '../types/message.js'
|
|
|
|
export interface Entity {
|
|
id: string
|
|
type: string // e.g., 'system', 'preference', 'credential'
|
|
name: string // e.g., 'RHEL9', 'Jira URL'
|
|
attributes: Record<string, string>
|
|
}
|
|
|
|
export interface Relation {
|
|
sourceId: string
|
|
targetId: string
|
|
type: string // e.g., 'runs_on', 'configured_as'
|
|
}
|
|
|
|
export interface KnowledgeGraph {
|
|
entities: Record<string, Entity>
|
|
relations: Relation[]
|
|
}
|
|
|
|
export interface ConversationArc {
|
|
id: string
|
|
goals: Goal[]
|
|
decisions: Decision[]
|
|
milestones: Milestone[]
|
|
knowledgeGraph: KnowledgeGraph
|
|
currentPhase: 'init' | 'exploring' | 'implementing' | 'reviewing' | 'completed'
|
|
startTime: number
|
|
lastUpdateTime: number
|
|
}
|
|
|
|
export interface Goal {
|
|
id: string
|
|
description: string
|
|
status: 'pending' | 'active' | 'completed' | 'abandoned'
|
|
createdAt: number
|
|
completedAt?: number
|
|
}
|
|
|
|
export interface Decision {
|
|
id: string
|
|
description: string
|
|
rationale?: string
|
|
timestamp: number
|
|
}
|
|
|
|
export interface Milestone {
|
|
id: string
|
|
description: string
|
|
achievedAt: number
|
|
}
|
|
|
|
const ARC_KEYWORDS = {
|
|
init: ['start', 'begin', 'help', 'please'],
|
|
exploring: ['check', 'find', 'look', 'what', 'how', 'where', 'show'],
|
|
implementing: ['write', 'create', 'add', 'fix', 'update', 'modify', 'implement'],
|
|
reviewing: ['test', 'review', 'verify', 'check', 'ensure'],
|
|
completed: ['done', 'complete', 'finished', 'ready', 'good'],
|
|
}
|
|
|
|
let conversationArc: ConversationArc | null = null
|
|
|
|
export function initializeArc(): ConversationArc {
|
|
conversationArc = {
|
|
id: `arc_${Date.now()}`,
|
|
goals: [],
|
|
decisions: [],
|
|
milestones: [],
|
|
knowledgeGraph: {
|
|
entities: {},
|
|
relations: [],
|
|
},
|
|
currentPhase: 'init',
|
|
startTime: Date.now(),
|
|
lastUpdateTime: Date.now(),
|
|
}
|
|
return conversationArc
|
|
}
|
|
|
|
export function getArc(): ConversationArc | null {
|
|
if (!conversationArc) {
|
|
return initializeArc()
|
|
}
|
|
return conversationArc
|
|
}
|
|
|
|
function extractTextFromContent(content: unknown): string {
|
|
if (!content) return ''
|
|
if (typeof content === 'string') return content
|
|
if (Array.isArray(content)) {
|
|
return content
|
|
.filter((block: any) => block.type === 'text' && typeof block.text === 'string')
|
|
.map((block: any) => block.text)
|
|
.join('\\n')
|
|
}
|
|
return ''
|
|
}
|
|
|
|
function detectPhase(content: string): ConversationArc['currentPhase'] | null {
|
|
const lower = content.toLowerCase()
|
|
|
|
for (const [phase, keywords] of Object.entries(ARC_KEYWORDS)) {
|
|
if (keywords.some(k => lower.includes(k))) {
|
|
return phase as ConversationArc['currentPhase']
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function extractFactsAutomatically(content: string): void {
|
|
const arc = getArc()
|
|
if (!arc) return
|
|
|
|
// 1. Detect Environment Variables (KEY=VALUE) - strictly uppercase keys
|
|
const envMatches = content.matchAll(/(?:export\s+)?([A-Z_]{3,})=([^\s\n"']+)/g)
|
|
for (const match of envMatches) {
|
|
addEntity('environment_variable', match[1], { value: match[2] })
|
|
}
|
|
|
|
// 2. Detect Absolute Paths - ensure it looks like a path and not a div or code
|
|
const pathMatches = content.matchAll(/(\/(?:[\w.-]+\/)+[\w.-]+)/g)
|
|
for (const match of pathMatches) {
|
|
const path = match[1]
|
|
// Exclude common noise and ensure it's a long enough path
|
|
if (path.length > 8 && !path.includes('node_modules') && !path.includes('://')) {
|
|
addEntity('path', path, { type: 'absolute' })
|
|
}
|
|
}
|
|
|
|
// 3. Detect Versions - require vX.Y.Z or version X.Y.Z
|
|
const versionMatches = content.matchAll(/(?:v|version\s+)(\d+\.\d+(?:\.\d+)?)/gi)
|
|
for (const match of versionMatches) {
|
|
addEntity('version', match[0].toLowerCase(), { semver: match[1] })
|
|
}
|
|
|
|
// 4. Detect Hostnames/URLs
|
|
const urlMatches = content.matchAll(/(https?:\/\/[^\s\n"']+)/g)
|
|
for (const match of urlMatches) {
|
|
try {
|
|
const url = new URL(match[1])
|
|
if (url.hostname.includes('.')) {
|
|
addEntity('endpoint', url.hostname, { url: url.toString() })
|
|
}
|
|
} catch {
|
|
// Ignore invalid URLs
|
|
}
|
|
}
|
|
}
|
|
|
|
export function updateArcPhase(messages: Message[]): void {
|
|
const arc = getArc()
|
|
if (!arc) return
|
|
|
|
for (const msg of messages.slice(-5).reverse()) {
|
|
const content = extractTextFromContent(msg.message?.content)
|
|
if (!content) continue
|
|
|
|
// Phase detection
|
|
const detected = detectPhase(content)
|
|
if (detected && detected !== arc.currentPhase) {
|
|
const phaseOrder = [
|
|
'init',
|
|
'exploring',
|
|
'implementing',
|
|
'reviewing',
|
|
'completed',
|
|
]
|
|
const oldIdx = phaseOrder.indexOf(arc.currentPhase)
|
|
const newIdx = phaseOrder.indexOf(detected)
|
|
|
|
if (newIdx > oldIdx) {
|
|
arc.currentPhase = detected
|
|
arc.lastUpdateTime = Date.now()
|
|
}
|
|
}
|
|
|
|
// NEW: Passive fact extraction (Automatic Learning)
|
|
extractFactsAutomatically(content)
|
|
}
|
|
}
|
|
|
|
export function addGoal(description: string): Goal {
|
|
const arc = getArc()
|
|
if (!arc) throw new Error('Arc not initialized')
|
|
|
|
const goal: Goal = {
|
|
id: `goal_${Date.now()}`,
|
|
description,
|
|
status: 'pending',
|
|
createdAt: Date.now(),
|
|
}
|
|
|
|
arc.goals.push(goal)
|
|
arc.lastUpdateTime = Date.now()
|
|
|
|
if (arc.currentPhase === 'init') {
|
|
arc.currentPhase = 'exploring'
|
|
}
|
|
|
|
return goal
|
|
}
|
|
|
|
export function updateGoalStatus(goalId: string, status: Goal['status']): void {
|
|
const arc = getArc()
|
|
if (!arc) return
|
|
|
|
const goal = arc.goals.find(g => g.id === goalId)
|
|
if (!goal) return
|
|
|
|
goal.status = status
|
|
if (status === 'completed') {
|
|
goal.completedAt = Date.now()
|
|
addMilestone(`Completed: ${goal.description}`)
|
|
}
|
|
|
|
arc.lastUpdateTime = Date.now()
|
|
}
|
|
|
|
export function addDecision(description: string, rationale?: string): Decision {
|
|
const arc = getArc()
|
|
if (!arc) throw new Error('Arc not initialized')
|
|
|
|
const decision: Decision = {
|
|
id: `decision_${Date.now()}`,
|
|
description,
|
|
rationale,
|
|
timestamp: Date.now(),
|
|
}
|
|
|
|
arc.decisions.push(decision)
|
|
arc.lastUpdateTime = Date.now()
|
|
|
|
return decision
|
|
}
|
|
|
|
export function addMilestone(description: string): Milestone {
|
|
const arc = getArc()
|
|
if (!arc) throw new Error('Arc not initialized')
|
|
|
|
const milestone: Milestone = {
|
|
id: `milestone_${Date.now()}`,
|
|
description,
|
|
achievedAt: Date.now(),
|
|
}
|
|
|
|
arc.milestones.push(milestone)
|
|
arc.lastUpdateTime = Date.now()
|
|
|
|
return milestone
|
|
}
|
|
|
|
export function addEntity(
|
|
type: string,
|
|
name: string,
|
|
attributes: Record<string, string> = {},
|
|
): Entity {
|
|
const arc = getArc()
|
|
if (!arc) throw new Error('Arc not initialized')
|
|
|
|
// Check for existing entity to avoid duplicates (Deduplication Logic)
|
|
const existingEntity = Object.values(arc.knowledgeGraph.entities).find(
|
|
e => e.type === type && e.name === name,
|
|
)
|
|
|
|
if (existingEntity) {
|
|
existingEntity.attributes = { ...existingEntity.attributes, ...attributes }
|
|
arc.lastUpdateTime = Date.now()
|
|
return existingEntity
|
|
}
|
|
|
|
const id = `entity_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`
|
|
const entity: Entity = { id, type, name, attributes }
|
|
|
|
arc.knowledgeGraph.entities[id] = entity
|
|
arc.lastUpdateTime = Date.now()
|
|
return entity
|
|
}
|
|
|
|
export function addRelation(
|
|
sourceId: string,
|
|
targetId: string,
|
|
type: string,
|
|
): void {
|
|
const arc = getArc()
|
|
if (!arc) throw new Error('Arc not initialized')
|
|
|
|
if (!arc.knowledgeGraph.entities[sourceId] || !arc.knowledgeGraph.entities[targetId]) {
|
|
throw new Error('Source or target entity not found in graph')
|
|
}
|
|
|
|
arc.knowledgeGraph.relations.push({ sourceId, targetId, type })
|
|
arc.lastUpdateTime = Date.now()
|
|
}
|
|
|
|
export function getGraphSummary(): string {
|
|
const arc = getArc()
|
|
if (!arc || Object.keys(arc.knowledgeGraph.entities).length === 0) {
|
|
return ''
|
|
}
|
|
|
|
let summary = '\\nKnowledge Graph:\\n'
|
|
for (const entity of Object.values(arc.knowledgeGraph.entities)) {
|
|
summary += `- [${entity.type}] ${entity.name}`
|
|
const attrs = Object.entries(entity.attributes)
|
|
if (attrs.length > 0) {
|
|
summary += ` (${attrs.map(([k, v]) => `${k}: ${v}`).join(', ')})`
|
|
}
|
|
summary += '\\n'
|
|
}
|
|
|
|
for (const rel of arc.knowledgeGraph.relations) {
|
|
const src = arc.knowledgeGraph.entities[rel.sourceId]?.name
|
|
const tgt = arc.knowledgeGraph.entities[rel.targetId]?.name
|
|
if (src && tgt) {
|
|
summary += `- ${src} --(${rel.type})--> ${tgt}\\n`
|
|
}
|
|
}
|
|
|
|
return summary
|
|
}
|
|
|
|
export function getArcSummary(): string {
|
|
const arc = getArc()
|
|
if (!arc) return 'No conversation arc'
|
|
|
|
const activeGoals = arc.goals.filter(
|
|
g => g.status === 'active' || g.status === 'pending',
|
|
)
|
|
const completedGoals = arc.goals.filter(g => g.status === 'completed')
|
|
|
|
let summary = `Phase: ${arc.currentPhase}\\n`
|
|
summary += `Goals: ${completedGoals.length}/${arc.goals.length} completed\\n`
|
|
|
|
if (activeGoals.length > 0) {
|
|
summary += `Active: ${activeGoals[0].description.slice(0, 50)}...\\n`
|
|
}
|
|
|
|
if (arc.decisions.length > 0) {
|
|
summary += `Decisions: ${arc.decisions.length}\\n`
|
|
}
|
|
|
|
if (arc.milestones.length > 0) {
|
|
summary += `Latest milestone: ${arc.milestones[
|
|
arc.milestones.length - 1
|
|
].description.slice(0, 40)}`
|
|
}
|
|
|
|
summary += getGraphSummary()
|
|
|
|
return summary
|
|
}
|
|
|
|
export function resetArc(): void {
|
|
conversationArc = null
|
|
}
|
|
|
|
export function getArcStats() {
|
|
const arc = getArc()
|
|
if (!arc) return null
|
|
|
|
return {
|
|
phase: arc.currentPhase,
|
|
goalCount: arc.goals.length,
|
|
completedGoals: arc.goals.filter(g => g.status === 'completed').length,
|
|
decisionCount: arc.decisions.length,
|
|
milestoneCount: arc.milestones.length,
|
|
durationMs: arc.lastUpdateTime - arc.startTime,
|
|
}
|
|
}
|