Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1",
"@opentelemetry/semantic-conventions": "1.40.0",
"@orama/orama": "^3.1.18",
"@orama/plugin-data-persistence": "^3.1.18",
"@vscode/ripgrep": "^1.17.1",
"ajv": "8.18.0",
"auto-bind": "5.0.1",
Expand Down
3 changes: 3 additions & 0 deletions scripts/externals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ export const COMMON_EXTERNALS: string[] = [
// would freeze the build host's absolute path into dist/cli.mjs, so we
// keep it external and rely on the npm package being installed.
'@vscode/ripgrep',
// Orama search engine
'@orama/orama',
'@orama/plugin-data-persistence',
]

// Additional packages external only in the SDK bundle (TUI + heavy deps)
Expand Down
2 changes: 1 addition & 1 deletion src/commands/insights.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ import { toError } from '../utils/errors.js'
import { logError } from '../utils/log.js'
import { extractTextContent } from '../utils/messages.js'
import { getDefaultOpusModel } from '../utils/model/model.js'
import { getProjectsDir } from '../utils/envUtils.js'
import {
getProjectsDir,
getSessionFilesWithMtime,
getSessionIdFromLog,
loadAllLogsFromSessionFile,
Expand Down
2 changes: 1 addition & 1 deletion src/commands/knowledge/knowledge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const call: LocalCommandCall = async (args, _context) => {
}

if (subCommand === 'list') {
return { type: 'text', value: getArcSummary() };
return { type: 'text', value: await getArcSummary() };
}

return {
Expand Down
6 changes: 3 additions & 3 deletions src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,7 @@ async function* queryLoop(
messagesForQuery.length > 0
) {
const { updateArcPhase } = await import('./utils/conversationArc.js')
updateArcPhase([messagesForQuery[messagesForQuery.length - 1]])
await updateArcPhase([messagesForQuery[messagesForQuery.length - 1]])
}

let tracking = autoCompactTracking
Expand Down Expand Up @@ -489,7 +489,7 @@ async function* queryLoop(
? lastMessage.message.content
: ''
const { getArcSummary } = await import('./utils/conversationArc.js')
const arcSummary = getArcSummary(userQueryText)
const arcSummary = await getArcSummary(userQueryText)
if (arcSummary) {
promptWithArc = [...systemPrompt, arcSummary]
}
Expand Down Expand Up @@ -1585,7 +1585,7 @@ async function* queryLoop(
getGlobalConfig().knowledgeGraphEnabled
) {
const { updateArcPhase } = await import('./utils/conversationArc.js')
updateArcPhase([assistantMessage])
await updateArcPhase([assistantMessage])
}

// Generate tool use summary after tool batch completes — passed to next recursive call
Expand Down
2 changes: 1 addition & 1 deletion src/utils/cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import * as lockfile from './lockfile.js'
import { logError } from './log.js'
import { cleanupOldVersions } from './nativeInstaller/index.js'
import { cleanupOldPastes } from './pasteStore.js'
import { getProjectsDir } from './sessionStorage.js'
import { getProjectsDir } from './envUtils.js'
import { getSettingsWithAllErrors } from './settings/allErrors.js'
import {
getSettings_DEPRECATED,
Expand Down
37 changes: 19 additions & 18 deletions src/utils/conversationArc.perf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,50 +19,51 @@ describe('Conversation Arc Performance Benchmarks', () => {
initializeArc()
})

it('performs automatic fact extraction in sub-millisecond time', () => {
it('performs automatic fact extraction in sub-millisecond time', async () => {
const iterations = 100
const complexContent = 'Deploying version v1.2.3 to /opt/prod/server on https://api.prod.local with JIRA_URL=https://jira.corp'

const complexContent =
'Deploying version v1.2.3 to /opt/prod/server on https://api.prod.local with JIRA_URL=https://jira.corp'

const startTime = performance.now()
for (let i = 0; i < iterations; i++) {
updateArcPhase([createMessage(complexContent)])
await updateArcPhase([createMessage(complexContent)])
}
const duration = performance.now() - startTime
const averageTime = duration / iterations

console.log(`[Benchmark] Avg extraction time: ${averageTime.toFixed(4)}ms`)
// Performance guard: should definitely be under 2.0ms per message on any modern CI
// (Monster engine is more complex than initial version)
expect(averageTime).toBeLessThan(2.0)

// Performance guard: should definitely be under 5.0ms per message on any modern CI
// (Async overhead and Orama checks add some cost)
expect(averageTime).toBeLessThan(5.0)
})

it('generates summaries quickly even with a populated graph', () => {
it('generates summaries quickly even with a populated graph', async () => {
// Populate graph with 50 facts
for (let i = 0; i < 50; i++) {
updateArcPhase([createMessage(`Var_${i}=Value_${i} in /path/to/file_${i}`)])
await updateArcPhase([createMessage(`Var_${i}=Value_${i} in /path/to/file_${i}`)])
}

const startTime = performance.now()
const summary = getArcSummary()
const summary = await getArcSummary()
const duration = performance.now() - startTime

console.log(`[Benchmark] Summary generation time (50 entities): ${duration.toFixed(4)}ms`)
expect(summary).toMatch(/Knowledge Graph/);
// Summary generation should be extremely fast
expect(duration).toBeLessThan(10)
expect(summary).toMatch(/Knowledge Graph/)
// Summary generation should be fast
expect(duration).toBeLessThan(50)
})

it('maintains a compact memory footprint', () => {
it('maintains a compact memory footprint', async () => {
const arc = initializeArc()
for (let i = 0; i < 100; i++) {
updateArcPhase([createMessage(`Fact_${i}=Value_${i}`)])
await updateArcPhase([createMessage(`Fact_${i}=Value_${i}`)])
}

const serialized = JSON.stringify(arc)
const sizeKB = serialized.length / 1024
console.log(`[Benchmark] Memory footprint (100 facts): ${sizeKB.toFixed(2)}KB`)

// Should be well under 100KB for 100 simple facts
expect(sizeKB).toBeLessThan(100)
})
Expand Down
63 changes: 34 additions & 29 deletions src/utils/conversationArc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,40 +41,43 @@ describe('conversationArc', () => {
})

describe('Knowledge Graph', () => {
it('adds entities and relations', () => {
it('adds entities and relations', async () => {
initializeArc()
const e1 = addEntity('system', 'RHEL9', { version: '9.4' })
const e2 = addEntity('credential', 'Jira PAT')
const e1 = await addEntity('system', 'RHEL9', { version: '9.4' })
const e2 = await addEntity('credential', 'Jira PAT')

expect(e1.name).toBe('RHEL9')
expect(e1.attributes.version).toBe('9.4')

addRelation(e1.id, e2.id, 'requires')
await addRelation(e1.id, e2.id, 'requires')

const graph = getGlobalGraph()
expect(Object.keys(graph.entities).length).toBeGreaterThanOrEqual(2)
expect(graph.relations.some(r => r.type === 'requires')).toBe(true)
})

it('generates a knowledge graph summary', () => {
it('generates a knowledge graph summary', async () => {
resetGlobalGraph()
initializeArc()
const e1 = addEntity('system', 'RHEL-TEST', { os: 'linux' })
const e2 = addEntity('feature', 'OpenClaude-TEST')
addRelation(e2.id, e1.id, 'runs_on')
const e1 = await addEntity('system', 'RHEL-TEST', { os: 'linux' })
const e2 = await addEntity('feature', 'OpenClaude-TEST')
await addRelation(e2.id, e1.id, 'runs_on')

const summary = getArcSummary()
expect(summary).toMatch(/Knowledge Graph/);
const summary = await getArcSummary()
expect(summary).toMatch(/Knowledge Graph/)
expect(summary).toContain('[system] RHEL-TEST')
expect(summary).toMatch(/os: linux/);
expect(summary).toMatch(/os: linux/)
})

it('automatically learns facts from message content', () => {
it('automatically learns facts from message content', async () => {
resetGlobalGraph()
initializeArc()
const complexMessage = createMessage('user', 'Set JIRA_URL_TEST=https://jira.local and look in /opt/app/bin/test version v1.2.3')
const complexMessage = createMessage(
'user',
'Set JIRA_URL_TEST=https://jira.local and look in /opt/app/bin/test version v1.2.3',
)

updateArcPhase([complexMessage])
await updateArcPhase([complexMessage])

const summary = getGraphSummary()
expect(summary).toContain('JIRA_URL_TEST')
Expand All @@ -83,25 +86,27 @@ describe('conversationArc', () => {
expect(summary).toContain('v1.2.3')
})

it('throws error when adding relation to non-existent entity', () => {
it('throws error when adding relation to non-existent entity', async () => {
initializeArc()
expect(() => addRelation('invalid1', 'invalid2', 'test')).toThrow('Source or target entity not found in graph')
await expect(addRelation('invalid1', 'invalid2', 'test')).rejects.toThrow(
'Source or target entity not found in graph',
)
})
})

describe('finalizeArcTurn', () => {
it('generates and persists a summary of the turn', () => {
it('generates and persists a summary of the turn', async () => {
initializeArc()
addGoal('Build RAG engine')
updateGoalStatus(getArc()!.goals[0].id, 'completed')
addDecision('Use JSON for storage')

finalizeArcTurn()
await finalizeArcTurn()

const summary = getGraphSummary()
expect(summary).toMatch(/Knowledge Graph/);
expect(summary).toMatch(/Knowledge Graph/)
// searchGlobalGraph should now find it
const ragResult = getArcSummary('Tell me about the RAG engine')
const ragResult = await getArcSummary('Tell me about the RAG engine')
expect(ragResult).toContain('Build RAG engine')
expect(ragResult).toContain('Use JSON for storage')
})
Expand All @@ -116,14 +121,14 @@ describe('conversationArc', () => {
})

describe('updateArcPhase', () => {
it('detects exploring phase', () => {
it('detects exploring phase', async () => {
initializeArc()
updateArcPhase([createMessage('user', 'Find the file')])
await updateArcPhase([createMessage('user', 'Find the file')])

expect(getArc()?.currentPhase).toBe('exploring')
})

it('detects phase from block array content', () => {
it('detects phase from block array content', async () => {
initializeArc()
const blockMessage = {
message: {
Expand All @@ -135,15 +140,15 @@ describe('conversationArc', () => {
},
sender: 'assistant',
}
updateArcPhase([blockMessage as any])
await updateArcPhase([blockMessage as any])

expect(getArc()?.currentPhase).toBe('implementing')
})

it('progresses phases forward only', () => {
it('progresses phases forward only', async () => {
initializeArc()
updateArcPhase([createMessage('user', 'Write code')])
updateArcPhase([createMessage('user', 'Find file')])
await updateArcPhase([createMessage('user', 'Write code')])
await updateArcPhase([createMessage('user', 'Find file')])

// Phase should remain at implementing since it was detected first
expect(getArc()?.currentPhase).toBe('implementing')
Expand Down Expand Up @@ -188,10 +193,10 @@ describe('conversationArc', () => {
})

describe('getArcSummary', () => {
it('returns summary string', () => {
it('returns summary string', async () => {
initializeArc()
addGoal('Test goal')
const summary = getArcSummary()
const summary = await getArcSummary()

expect(summary).toContain('Phase:')
expect(summary).toContain('Goals:')
Expand Down
Loading
Loading