From 1f7bfecb19ec99597fadd805bbe06f67fd7c7465 Mon Sep 17 00:00:00 2001 From: kentwynn Date: Wed, 27 May 2026 12:31:31 +0700 Subject: [PATCH] feat: session token metrics, claude-code hooks, and session panel in visualize - Record packUsedTokens/packOmittedTokens on context events (moved recordSessionEvent call to after buildContextPack so token data exists) - Add pack stats aggregation to SessionReport (packCallCount, totalPackUsedTokens, totalPackOmittedTokens) - Show Pack Usage section in kgraph session output with filter rate % - Register claude-code hooks in .claude/settings.json on integrate add (UserPromptSubmit, PreToolUse/Read, PostToolUse/Write, Stop) so sessions fire automatically without AI cooperation - Add configFiles extension point to IntegrationAdapter for JSON config merging/removal on integrate add/remove - Fix manual mode instruction to include --agent flag in pack command - Add lazy Session panel to kgraph visualize toolbar: click to render pack call metrics, per-call token bars, and I/O counts on demand --- src/cli/commands/pack.ts | 18 ++- src/cli/commands/session.ts | 112 ++++++++++++++--- src/cli/commands/visualize.ts | 45 ++++++- src/integrations/adapters/claude-code.ts | 118 +++++++++++++++++- src/integrations/instruction-blocks.ts | 2 +- src/integrations/integration-registry.ts | 10 ++ src/integrations/integration-store.ts | 67 ++++++++++- src/session/session-store.ts | 20 ++++ src/types/session.ts | 11 +- src/visualization/html-template.ts | 146 ++++++++++++++++++++++- tests/integration/integrate.test.ts | 11 +- tests/integration/session.test.ts | 113 ++++++++++++++---- 12 files changed, 618 insertions(+), 55 deletions(-) diff --git a/src/cli/commands/pack.ts b/src/cli/commands/pack.ts index 15af583..5ed5681 100644 --- a/src/cli/commands/pack.ts +++ b/src/cli/commands/pack.ts @@ -46,19 +46,25 @@ export function registerPackCommand(program: Command): void { options.agent ?? (command.getOptionValue('agent') as string | undefined) ?? findCommandOption(command, 'agent'); + const [config, maps] = await Promise.all([ + loadConfig(workspace), + readMaps(workspace), + ]); + const response = await queryContext(workspace, config, maps, task); + const pack = buildContextPack(response, budget, workspace.rootPath); if (agent) { + const omittedTokens = pack.omitted.reduce( + (sum, item) => sum + item.tokenEstimate, + 0, + ); await recordSessionEvent(workspace, { agent: assertSessionAgent(agent), type: 'context', captureSource: 'automatic', + packUsedTokens: pack.usedTokens, + packOmittedTokens: omittedTokens, }); } - const [config, maps] = await Promise.all([ - loadConfig(workspace), - readMaps(workspace), - ]); - const response = await queryContext(workspace, config, maps, task); - const pack = buildContextPack(response, budget, workspace.rootPath); const pendingInboxFiles = (await listInboxNotes(workspace)).map( (file) => path.relative(workspace.rootPath, file).split(path.sep).join('/'), diff --git a/src/cli/commands/session.ts b/src/cli/commands/session.ts index fe3c42f..44130f6 100644 --- a/src/cli/commands/session.ts +++ b/src/cli/commands/session.ts @@ -10,9 +10,9 @@ import { recordSessionEvent, resetSession, } from '../../session/session-store.js'; -import type { SessionCaptureSource } from '../../types/session.js'; import { assertWorkspace } from '../../storage/kgraph-paths.js'; import { readMaps } from '../../storage/map-store.js'; +import type { SessionCaptureSource } from '../../types/session.js'; import { KGraphError, runCommand } from '../errors.js'; import { normalizeConfidence, normalizeKind } from './conclude.js'; @@ -37,14 +37,22 @@ export function registerSessionCommand(program: Command): void { runCommand(async () => { const workspace = await assertWorkspace(process.cwd()); const report = await buildSessionReport(workspace); - console.log(options.json ? JSON.stringify(report, null, 2) : renderSessionReport(report)); + console.log( + options.json + ? JSON.stringify(report, null, 2) + : renderSessionReport(report), + ); }), ); session .command('start') .option('--agent ', 'KGraph integration agent name') - .option('--source ', 'automatic, agent-reported, or manual', 'manual') + .option( + '--source ', + 'automatic, agent-reported, or manual', + 'manual', + ) .action((options: SessionOptions, command: Command) => runCommand(async () => { const workspace = await assertWorkspace(process.cwd()); @@ -60,7 +68,11 @@ export function registerSessionCommand(program: Command): void { session .command('read ') .option('--agent ', 'KGraph integration agent name') - .option('--source ', 'automatic, agent-reported, or manual', 'manual') + .option( + '--source ', + 'automatic, agent-reported, or manual', + 'manual', + ) .action((filePath: string, options: SessionOptions, command: Command) => runCommand(async () => { const workspace = await assertWorkspace(process.cwd()); @@ -81,7 +93,11 @@ export function registerSessionCommand(program: Command): void { session .command('write ') .option('--agent ', 'KGraph integration agent name') - .option('--source ', 'automatic, agent-reported, or manual', 'manual') + .option( + '--source ', + 'automatic, agent-reported, or manual', + 'manual', + ) .action((filePath: string, options: SessionOptions, command: Command) => runCommand(async () => { const workspace = await assertWorkspace(process.cwd()); @@ -93,17 +109,27 @@ export function registerSessionCommand(program: Command): void { captureSource: normalizeSource(options.source), fileMap: maps.fileMap, }); - console.log(`KGraph recorded write: ${event.path}${event.tokenEstimate !== undefined ? ` ~${event.tokenEstimate} tokens` : ''}.`); + console.log( + `KGraph recorded write: ${event.path}${event.tokenEstimate !== undefined ? ` ~${event.tokenEstimate} tokens` : ''}.`, + ); }), ); session .command('end') .option('--agent ', 'KGraph integration agent name') - .option('--source ', 'automatic, agent-reported, or manual', 'manual') + .option( + '--source ', + 'automatic, agent-reported, or manual', + 'manual', + ) .option('--conclude', 'Store a durable typed summary for this session') .option('--topic ', 'Conclusion topic when using --conclude') - .option('--type ', 'finding, decision, gotcha, summary, or relationship', 'summary') + .option( + '--type ', + 'finding, decision, gotcha, summary, or relationship', + 'summary', + ) .option('--confidence ', 'high, medium, or low', 'medium') .option('--note ', 'Concise durable conclusion text') .action((options: SessionOptions, command: Command) => @@ -151,20 +177,62 @@ export function registerSessionCommand(program: Command): void { ); } -export function renderSessionReport(report: Awaited>): string { +export function renderSessionReport( + report: Awaited>, +): string { const lines = ['', 'KGraph Session', '']; - lines.push(`Active agents: ${report.activeAgents.length === 0 ? 'none' : report.activeAgents.map((agent) => agent.agent).join(', ')}`); + lines.push( + `Active agents: ${report.activeAgents.length === 0 ? 'none' : report.activeAgents.map((agent) => agent.agent).join(', ')}`, + ); lines.push(`Reads: ${report.readCount}`); lines.push(`Writes: ${report.writeCount}`); lines.push(`Repeated reads: ${report.repeatedReadCount}`); lines.push(`Estimated read tokens: ${report.estimatedReadTokens}`); - lines.push(`Estimated repeated-read tokens: ${report.estimatedRepeatedReadTokens}`); + lines.push( + `Estimated repeated-read tokens: ${report.estimatedRepeatedReadTokens}`, + ); + lines.push(''); + lines.push('Pack Usage'); + lines.push(` Pack calls: ${report.packCallCount}`); + lines.push(` Tokens used: ${report.totalPackUsedTokens}`); + lines.push(` Tokens filtered: ${report.totalPackOmittedTokens}`); + if (report.packCallCount > 0 && report.totalPackOmittedTokens > 0) { + const total = report.totalPackUsedTokens + report.totalPackOmittedTokens; + const pct = Math.round((report.totalPackOmittedTokens / total) * 100); + lines.push( + ` Filter rate: ${pct}% of candidate tokens excluded from context`, + ); + } lines.push('', 'Top Repeated Reads'); - lines.push(...formatList(report.topRepeatedReads.map((item) => `- ${item.path} read ${item.count} times (~${item.estimatedTokens} tokens)`))); + lines.push( + ...formatList( + report.topRepeatedReads.map( + (item) => + `- ${item.path} read ${item.count} times (~${item.estimatedTokens} tokens)`, + ), + ), + ); lines.push('', 'Recent Events'); - lines.push(...formatList(report.recentEvents.map((event) => `- ${event.agent} ${event.type}${event.path ? ` ${event.path}` : ''} [${event.captureSource}]`))); + lines.push( + ...formatList( + report.recentEvents.map((event) => { + const packInfo = + event.packUsedTokens !== undefined + ? ` [used:${event.packUsedTokens} filtered:${event.packOmittedTokens ?? 0}]` + : ''; + return `- ${event.agent} ${event.type}${event.path ? ` ${event.path}` : ''}${packInfo} [${event.captureSource}]`; + }), + ), + ); lines.push('', 'Recent Ledger'); - lines.push(...formatList(report.ledger.map((entry) => `- ${entry.agent} ${entry.readCount} reads, ${entry.writeCount} writes, ${entry.repeatedReadCount} repeated`))); + lines.push( + ...formatList( + report.ledger.map( + (entry) => + `- ${entry.agent} ${entry.readCount} reads, ${entry.writeCount} writes, ${entry.repeatedReadCount} repeated`, + ), + ), + ); lines.push('', 'Next'); lines.push(...sessionNextActions(report)); return lines.join('\n'); @@ -199,10 +267,16 @@ function findCommandOption( } function normalizeSource(value: string | undefined): SessionCaptureSource { - if (value === 'automatic' || value === 'agent-reported' || value === 'manual') { + if ( + value === 'automatic' || + value === 'agent-reported' || + value === 'manual' + ) { return value; } - throw new KGraphError('--source must be automatic, agent-reported, or manual.'); + throw new KGraphError( + '--source must be automatic, agent-reported, or manual.', + ); } function formatList(items: string[]): string[] { @@ -212,7 +286,11 @@ function formatList(items: string[]): string[] { function sessionNextActions( report: Awaited>, ): string[] { - if (report.readCount === 0 && report.writeCount === 0) { + if ( + report.packCallCount === 0 && + report.readCount === 0 && + report.writeCount === 0 + ) { return [ '- Start tracking with `kgraph session start --agent `.', '- Record meaningful reads/writes with `kgraph session read --agent ` and `kgraph session write --agent `.', diff --git a/src/cli/commands/visualize.ts b/src/cli/commands/visualize.ts index 64306ba..ee0679c 100644 --- a/src/cli/commands/visualize.ts +++ b/src/cli/commands/visualize.ts @@ -3,10 +3,14 @@ import { exec } from 'node:child_process'; import { createServer } from 'node:http'; import { loadConfig } from '../../config/config.js'; import { refreshKnowledgeAtomStatuses } from '../../knowledge/atom-store.js'; +import { readSessionState } from '../../session/session-store.js'; import { assertWorkspace } from '../../storage/kgraph-paths.js'; import { mapsExist, readMaps } from '../../storage/map-store.js'; import { buildGraph } from '../../visualization/graph-builder.js'; -import { renderHtml } from '../../visualization/html-template.js'; +import { + renderHtml, + type SessionVizData, +} from '../../visualization/html-template.js'; import { KGraphError, runCommand } from '../errors.js'; export function registerVisualizeCommand(program: Command): void { @@ -33,13 +37,48 @@ export function registerVisualizeCommand(program: Command): void { ); } - const maps = await readMaps(workspace); + const [maps, sessionState] = await Promise.all([ + readMaps(workspace), + readSessionState(workspace), + ]); const { atoms } = await refreshKnowledgeAtomStatuses(workspace, { fileMap: maps.fileMap, symbolMap: maps.symbolMap, }); await loadConfig(workspace); // ensure workspace is valid + const contextEvents = sessionState.events.filter( + (e) => e.type === 'context', + ); + const sessionVizData: SessionVizData | undefined = + sessionState.events.length > 0 + ? { + activeAgents: Object.values(sessionState.active).map( + (a) => a.agent, + ), + packCallCount: contextEvents.length, + totalPackUsedTokens: contextEvents.reduce( + (sum, e) => sum + (e.packUsedTokens ?? 0), + 0, + ), + totalPackOmittedTokens: contextEvents.reduce( + (sum, e) => sum + (e.packOmittedTokens ?? 0), + 0, + ), + readCount: sessionState.events.filter((e) => e.type === 'read') + .length, + writeCount: sessionState.events.filter( + (e) => e.type === 'write', + ).length, + contextEvents: contextEvents.map((e) => ({ + agent: e.agent, + packUsedTokens: e.packUsedTokens ?? 0, + packOmittedTokens: e.packOmittedTokens ?? 0, + timestamp: e.timestamp, + captureSource: e.captureSource, + })), + } + : undefined; const graphData = buildGraph( maps.fileMap, maps.symbolMap, @@ -47,7 +86,7 @@ export function registerVisualizeCommand(program: Command): void { maps.relationshipMap, atoms, ); - const html = renderHtml(graphData, workspace.rootPath); + const html = renderHtml(graphData, workspace.rootPath, sessionVizData); await serveGraph(html, port, options.open); }), diff --git a/src/integrations/adapters/claude-code.ts b/src/integrations/adapters/claude-code.ts index cf20c21..651c130 100644 --- a/src/integrations/adapters/claude-code.ts +++ b/src/integrations/adapters/claude-code.ts @@ -8,9 +8,16 @@ export const claudeCodeAdapter: IntegrationAdapter = { instructions: `## KGraph Workflow ${numberedWorkflow('claude-code', { - sessionQualifier: 'native hooks also report session activity when configured', + sessionQualifier: 'native hooks also report session activity automatically', })} `, + configFiles: [ + { + path: '.claude/settings.json', + merge: mergeClaudeHooks, + remove: removeClaudeHooks, + }, + ], commandFiles: [ { path: '.claude/commands/kgraph.md', @@ -109,6 +116,115 @@ ${numberedWorkflow('claude-code')} obsoleteCommandFiles: [], }; +type ClaudeHookCommand = { type: string; command: string }; +type ClaudeHookEntry = { matcher?: string; hooks: ClaudeHookCommand[] }; +type ClaudeHooks = Record; + +const KGRAPH_HOOK_MARKER = 'kgraph-session'; + +const CLAUDE_HOOK_REGISTRATIONS: Array<{ + event: string; + entry: ClaudeHookEntry; +}> = [ + { + event: 'UserPromptSubmit', + entry: { + hooks: [ + { + type: 'command', + command: 'node .claude/hooks/kgraph-session-start.cjs', + }, + ], + }, + }, + { + event: 'PreToolUse', + entry: { + matcher: 'Read', + hooks: [ + { + type: 'command', + command: 'node .claude/hooks/kgraph-session-pre-read.cjs', + }, + ], + }, + }, + { + event: 'PostToolUse', + entry: { + matcher: 'Write|Edit|MultiEdit', + hooks: [ + { + type: 'command', + command: 'node .claude/hooks/kgraph-session-post-write.cjs', + }, + ], + }, + }, + { + event: 'Stop', + entry: { + hooks: [ + { + type: 'command', + command: 'node .claude/hooks/kgraph-session-stop.cjs', + }, + ], + }, + }, +]; + +function mergeClaudeHooks( + existing: Record, +): Record { + const settings = { ...existing }; + const hooks: ClaudeHooks = + typeof settings.hooks === 'object' && settings.hooks !== null + ? { ...(settings.hooks as ClaudeHooks) } + : {}; + + for (const { event, entry } of CLAUDE_HOOK_REGISTRATIONS) { + const entries = (hooks[event] ?? []) as ClaudeHookEntry[]; + const alreadyPresent = entries.some((e) => + e.hooks.some((h) => h.command.includes(KGRAPH_HOOK_MARKER)), + ); + if (!alreadyPresent) { + hooks[event] = [...entries, entry]; + } + } + + settings.hooks = hooks; + return settings; +} + +function removeClaudeHooks( + existing: Record, +): Record { + if (typeof existing.hooks !== 'object' || existing.hooks === null) + return existing; + + const settings = { ...existing }; + const hooks: ClaudeHooks = { ...(settings.hooks as ClaudeHooks) }; + + for (const event of Object.keys(hooks)) { + hooks[event] = (hooks[event] as ClaudeHookEntry[]).filter( + (entry) => + !entry.hooks.some((h) => h.command.includes(KGRAPH_HOOK_MARKER)), + ); + if (hooks[event].length === 0) { + delete hooks[event]; + } + } + + if (Object.keys(hooks).length === 0) { + const { hooks: _removed, ...rest } = settings; + return rest; + } + + settings.hooks = hooks; + return settings; +} + function hookScript(event: 'start' | 'read' | 'write' | 'end'): string { const pathArg = event === 'read' || event === 'write' diff --git a/src/integrations/instruction-blocks.ts b/src/integrations/instruction-blocks.ts index 9fc3ac9..c8263c3 100644 --- a/src/integrations/instruction-blocks.ts +++ b/src/integrations/instruction-blocks.ts @@ -92,7 +92,7 @@ export function renderContextPolicy( case 'always': return `Every chat in this repository must use the correct KGraph command before answering or exploring files. ${routing} For normal repo context, code navigation, debugging, review, or edits, run \`${packCommand}\`. If that pack reports pending inbox notes, run \`${rootCommand}\` or \`kgraph update\` before relying on history or newly captured atoms. Infer the topic from the user's message. This records a lightweight KGraph session context event for the agent. ${useResultBoundary}`; case 'manual': - return 'Do not run KGraph automatically. Run `kgraph pack "" --budget 8000 --json` only when the user explicitly asks for KGraph context, invokes KGraph, or needs a machine-readable repo-memory pack. If the user explicitly asks for KGraph history, inbox/update, knowledge, or doctor, run that specific KGraph command instead of pack.'; + return `Do not run KGraph automatically. Run \`${packCommand}\` only when the user explicitly asks for KGraph context, invokes KGraph, or needs a machine-readable repo-memory pack. If the user explicitly asks for KGraph history, inbox/update, knowledge, or doctor, run that specific KGraph command instead of pack.`; case 'off': return 'KGraph is disabled for this integration.'; case 'smart': diff --git a/src/integrations/integration-registry.ts b/src/integrations/integration-registry.ts index dc16817..54d3cb1 100644 --- a/src/integrations/integration-registry.ts +++ b/src/integrations/integration-registry.ts @@ -13,6 +13,7 @@ export interface IntegrationAdapter { targetPath: string; instructions: string; commandFiles?: IntegrationCommandFile[]; + configFiles?: IntegrationConfigFile[]; obsoleteCommandFiles?: string[]; } @@ -21,6 +22,15 @@ export interface IntegrationCommandFile { content: string; } +export interface IntegrationConfigFile { + /** Repo-relative path to a JSON config file (e.g. `.claude/settings.json`). */ + path: string; + /** Merge KGraph entries into the existing parsed JSON object and return the result. */ + merge: (existing: Record) => Record; + /** Remove KGraph entries from the existing parsed JSON and return the result. */ + remove: (existing: Record) => Record; +} + const ADAPTERS: IntegrationAdapter[] = [ claudeCodeAdapter, clineAdapter, diff --git a/src/integrations/integration-store.ts b/src/integrations/integration-store.ts index 2cdc9d5..0711b23 100644 --- a/src/integrations/integration-store.ts +++ b/src/integrations/integration-store.ts @@ -20,6 +20,7 @@ import { removeManagedBlock, upsertManagedBlock, } from './instruction-blocks.js'; +import type { IntegrationConfigFile } from './integration-registry.js'; import { getIntegrationAdapter } from './integration-registry.js'; export interface IntegrationStatus { @@ -79,6 +80,10 @@ export async function addIntegrations( workspace.rootPath, adapter.commandFiles ?? [], ); + await removeIntegrationConfigFiles( + workspace.rootPath, + adapter.configFiles ?? [], + ); } else { await writeIntegrationInstructions( workspace.rootPath, @@ -99,6 +104,10 @@ export async function addIntegrations( for (const file of adapter.commandFiles ?? []) { writtenCommandFiles.add(file.path); } + await upsertIntegrationConfigFiles( + workspace.rootPath, + adapter.configFiles ?? [], + ); } await removeIntegrationCommandFiles( workspace.rootPath, @@ -160,6 +169,10 @@ export async function removeIntegrations( workspace.rootPath, adapter.obsoleteCommandFiles ?? [], ); + await removeIntegrationConfigFiles( + workspace.rootPath, + adapter.configFiles ?? [], + ); removed.push(adapter.name); } @@ -228,7 +241,10 @@ async function removeIntegrationCommandFiles( } } -async function pruneEmptyParents(rootPath: string, startDir: string): Promise { +async function pruneEmptyParents( + rootPath: string, + startDir: string, +): Promise { let dir = startDir; while (dir !== rootPath && dir.startsWith(rootPath)) { try { @@ -241,3 +257,52 @@ async function pruneEmptyParents(rootPath: string, startDir: string): Promise { + for (const configFile of configFiles) { + const fullPath = path.join(rootPath, configFile.path); + let existing: Record = {}; + if (await pathExists(fullPath)) { + try { + existing = JSON.parse(await readFile(fullPath, 'utf8')) as Record< + string, + unknown + >; + } catch { + existing = {}; + } + } + const next = configFile.merge(existing); + await mkdir(path.dirname(fullPath), { recursive: true }); + await writeFile(fullPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8'); + } +} + +async function removeIntegrationConfigFiles( + rootPath: string, + configFiles: IntegrationConfigFile[], +): Promise { + for (const configFile of configFiles) { + const fullPath = path.join(rootPath, configFile.path); + if (!(await pathExists(fullPath))) continue; + let existing: Record = {}; + try { + existing = JSON.parse(await readFile(fullPath, 'utf8')) as Record< + string, + unknown + >; + } catch { + continue; + } + const next = configFile.remove(existing); + if (Object.keys(next).length === 0) { + await rm(fullPath, { force: true }); + await pruneEmptyParents(rootPath, path.dirname(fullPath)); + } else { + await writeFile(fullPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8'); + } + } +} diff --git a/src/session/session-store.ts b/src/session/session-store.ts index c9266cf..eb5a4a2 100644 --- a/src/session/session-store.ts +++ b/src/session/session-store.ts @@ -58,6 +58,8 @@ export async function recordSessionEvent( path?: string; captureSource: SessionCaptureSource; fileMap?: FileMap; + packUsedTokens?: number; + packOmittedTokens?: number; }, ): Promise { const now = new Date().toISOString(); @@ -106,6 +108,12 @@ export async function recordSessionEvent( ...(normalizedPath ? { path: normalizedPath } : {}), ...(tokenEstimate !== undefined ? { tokenEstimate } : {}), ...(repeated !== undefined ? { repeated } : {}), + ...(input.type === 'context' && input.packUsedTokens !== undefined + ? { packUsedTokens: input.packUsedTokens } + : {}), + ...(input.type === 'context' && input.packOmittedTokens !== undefined + ? { packOmittedTokens: input.packOmittedTokens } + : {}), captureSource: input.captureSource, timestamp: now, }; @@ -141,6 +149,9 @@ export async function buildSessionReport( const readEvents = state.events.filter((event) => event.type === 'read'); const writeEvents = state.events.filter((event) => event.type === 'write'); const repeatedReads = readEvents.filter((event) => event.repeated); + const contextEvents = state.events.filter( + (event) => event.type === 'context', + ); return { activeAgents: Object.values(state.active), readCount: readEvents.length, @@ -148,6 +159,15 @@ export async function buildSessionReport( repeatedReadCount: repeatedReads.length, estimatedReadTokens: sumTokens(readEvents), estimatedRepeatedReadTokens: sumTokens(repeatedReads), + packCallCount: contextEvents.length, + totalPackUsedTokens: contextEvents.reduce( + (sum, e) => sum + (e.packUsedTokens ?? 0), + 0, + ), + totalPackOmittedTokens: contextEvents.reduce( + (sum, e) => sum + (e.packOmittedTokens ?? 0), + 0, + ), topRepeatedReads: topRepeatedReads(readEvents), recentEvents: state.events.slice(-10), ledger: ledger.slice(-10), diff --git a/src/types/session.ts b/src/types/session.ts index edace2e..9d36092 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -11,6 +11,8 @@ export interface SessionEvent { path?: string; tokenEstimate?: number; repeated?: boolean; + packUsedTokens?: number; + packOmittedTokens?: number; captureSource: SessionCaptureSource; timestamp: string; } @@ -47,7 +49,14 @@ export interface SessionReport { repeatedReadCount: number; estimatedReadTokens: number; estimatedRepeatedReadTokens: number; - topRepeatedReads: Array<{ path: string; count: number; estimatedTokens: number }>; + packCallCount: number; + totalPackUsedTokens: number; + totalPackOmittedTokens: number; + topRepeatedReads: Array<{ + path: string; + count: number; + estimatedTokens: number; + }>; recentEvents: SessionEvent[]; ledger: SessionLedgerEntry[]; } diff --git a/src/visualization/html-template.ts b/src/visualization/html-template.ts index 871e267..23ac830 100644 --- a/src/visualization/html-template.ts +++ b/src/visualization/html-template.ts @@ -1,13 +1,36 @@ import type { GraphData } from './graph-builder.js'; -export function renderHtml(graphData: GraphData, rootPath: string): string { +export interface SessionVizData { + activeAgents: string[]; + packCallCount: number; + totalPackUsedTokens: number; + totalPackOmittedTokens: number; + readCount: number; + writeCount: number; + contextEvents: Array<{ + agent: string; + packUsedTokens: number; + packOmittedTokens: number; + timestamp: string; + captureSource: string; + }>; +} + +export function renderHtml( + graphData: GraphData, + rootPath: string, + sessionData?: SessionVizData, +): string { const repoName = escAttr(rootPath.split(/[\\/]/).pop() ?? 'Repository'); const { meta } = graphData; // Prevent tag injection from embedded JSON const safeData = JSON.stringify(graphData).replace( /<\/script>/gi, - '<\\/script>', + '<\/script>', ); + const safeSessionData = sessionData + ? JSON.stringify(sessionData).replace(/<\/script>/gi, '<\/script>') + : null; return ` @@ -52,6 +75,31 @@ select:hover,button:hover{background:#475569} .li-dia{width:10px;height:10px;transform:rotate(45deg);flex-shrink:0;display:inline-block} .li-sep{width:1px;height:14px;background:#334155;flex-shrink:0} .li-head{font-size:11px;color:#475569;font-weight:700;letter-spacing:.04em} +#session-panel{width:310px;background:#1e293b;border-left:1px solid #334155;display:none;flex-direction:column;overflow:hidden;flex-shrink:0} +#session-panel.open{display:flex} +#btn-session{color:#7dd3fc;border-color:#3b82f6} +#btn-session.active,#btn-session:hover{background:#1e3a5f!important;color:#7dd3fc;border-color:#3b82f6} +#sp-head{display:flex;align-items:center;padding:12px 14px;border-bottom:1px solid #334155;gap:8px;flex-shrink:0} +#sp-title{font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#7dd3fc;flex-shrink:0} +#sp-agents{color:#475569;font-size:11px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +#sp-close2{background:none!important;border:none!important;color:#64748b;font-size:17px;line-height:1;cursor:pointer;padding:0 2px;flex-shrink:0;box-shadow:none} +#sp-close2:hover{color:#e2e8f0} +#sp-body{padding:14px;overflow-y:auto;flex:1;display:flex;flex-direction:column;gap:16px} +.sp-summary{display:grid;grid-template-columns:1fr 1fr;gap:6px} +.sp-metric{background:#0f172a;border:1px solid #334155;border-radius:5px;padding:8px 10px} +.sp-metric-val{font-size:18px;font-weight:700;color:#f1f5f9;line-height:1;font-variant-numeric:tabular-nums} +.sp-metric-val.accent{color:#7dd3fc} +.sp-metric-lbl{font-size:10px;color:#64748b;margin-top:3px;text-transform:uppercase;letter-spacing:.05em} +.sp-group-lbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#475569;padding-bottom:6px;margin-bottom:2px;border-bottom:1px solid #1e293b} +.sp-call-row{display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid #0f172a} +.sp-call-row:last-child{border-bottom:none} +.sp-call-time{font-size:10px;color:#475569;flex-shrink:0;width:44px} +.sp-call-bar-wrap{flex:1;background:#0f172a;border-radius:2px;height:6px;overflow:hidden} +.sp-call-bar{background:#3b82f6;height:100%;border-radius:2px;min-width:2px} +.sp-call-toks{font-size:11px;color:#94a3b8;flex-shrink:0;width:60px;text-align:right} +.sp-io-row{display:flex;gap:6px} +.sp-io-box{flex:1;background:#0f172a;border:1px solid #334155;border-radius:5px;padding:8px 10px;text-align:center} +.sp-io-val{font-size:18px;font-weight:700;color:#f1f5f9}.sp-io-lbl{font-size:10px;color:#64748b;margin-top:2px;text-transform:uppercase;letter-spacing:.05em} @@ -69,6 +117,7 @@ select:hover,button:hover{background:#475569} +${sessionData ? ` ` : ''}
@@ -80,6 +129,18 @@ select:hover,button:hover{background:#475569}
+${ + sessionData + ? `
+
+ \u26a1 Session + ${sessionData.activeAgents.length ? sessionData.activeAgents.join(' \xb7 ') : 'session recorded'} + +
+
+
` + : '' +}
Files @@ -300,6 +361,8 @@ select:hover,button:hover{background:#475569} document.getElementById('sb-type').textContent = d.type === 'atom' ? 'Knowledge Atom' : 'File'; document.getElementById('sb-body').innerHTML = d.type === 'atom' ? renderAtomPanel(d) : renderFilePanel(d); document.getElementById('sidebar').classList.add('open'); + var sp = document.getElementById('session-panel'); + if (sp) { sp.classList.remove('open'); var sb = document.getElementById('btn-session'); if (sb) { sb.classList.remove('active'); sessionPanelOpen = false; } } }); cy.on('tap', function (evt) { @@ -343,6 +406,85 @@ select:hover,button:hover{background:#475569} a.click(); document.body.removeChild(a); }); +${ + safeSessionData + ? ` + var SESSION_DATA = ${safeSessionData}; + var sessionRendered = false; + var sessionPanelOpen = false; + + function fmt(n) { + return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n); + } + + function renderSessionPanel() { + var d = SESSION_DATA; + var total = d.totalPackUsedTokens + d.totalPackOmittedTokens; + var filterRate = total > 0 ? Math.round((d.totalPackOmittedTokens / total) * 100) : 0; + var avgToks = d.packCallCount > 0 ? Math.round(d.totalPackUsedTokens / d.packCallCount) : 0; + var filterCell = filterRate > 0 + ? '
' + filterRate + '%
Filtered
' + : '
None
Filtered
'; + var summaryHtml = + '
' + + '
' + d.packCallCount + '
Pack calls
' + + '
' + fmt(d.totalPackUsedTokens) + '
Tokens used
' + + '
' + fmt(avgToks) + '
Avg / call
' + + filterCell + + '
'; + var maxUsed = 0; + d.contextEvents.forEach(function (e) { if ((e.packUsedTokens || 0) > maxUsed) maxUsed = e.packUsedTokens || 0; }); + var callRows = ''; + if (d.contextEvents.length > 0) { + callRows = '
Pack calls (' + d.contextEvents.length + ')
'; + d.contextEvents.forEach(function (e) { + var used = e.packUsedTokens || 0; + var filt = e.packOmittedTokens || 0; + var barPct = maxUsed > 0 ? Math.round(used / maxUsed * 100) : 100; + var ts = e.timestamp ? new Date(e.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''; + var filtLabel = filt > 0 ? ' -' + fmt(filt) + '' : ''; + callRows += + '
' + + '' + esc(ts) + '' + + '
' + + '' + fmt(used) + filtLabel + '' + + '
'; + }); + callRows += '
'; + } + var ioHtml = + '
File access
' + + '
' + + '
' + d.readCount + '
Reads
' + + '
' + d.writeCount + '
Writes
' + + '
'; + document.getElementById('sp-body').innerHTML = summaryHtml + callRows + ioHtml; + } + + document.getElementById('btn-session').addEventListener('click', function () { + var panel = document.getElementById('session-panel'); + sessionPanelOpen = !sessionPanelOpen; + if (sessionPanelOpen) { + panel.classList.add('open'); + this.classList.add('active'); + if (!sessionRendered) { + renderSessionPanel(); + sessionRendered = true; + } + } else { + panel.classList.remove('open'); + this.classList.remove('active'); + } + }); + + document.getElementById('sp-close2').addEventListener('click', function () { + document.getElementById('session-panel').classList.remove('open'); + document.getElementById('btn-session').classList.remove('active'); + sessionPanelOpen = false; + }); +` + : '' +} })(); diff --git a/tests/integration/integrate.test.ts b/tests/integration/integrate.test.ts index d4d0bb4..f3cdde2 100644 --- a/tests/integration/integrate.test.ts +++ b/tests/integration/integrate.test.ts @@ -73,6 +73,13 @@ describe('kgraph integrate', () => { await access( path.join(repo, '.claude', 'hooks', 'kgraph-session-start.cjs'), ); + const settings = JSON.parse( + await readFile(path.join(repo, '.claude', 'settings.json'), 'utf8'), + ); + expect(settings.hooks?.UserPromptSubmit).toBeDefined(); + expect(settings.hooks?.PreToolUse).toBeDefined(); + expect(settings.hooks?.PostToolUse).toBeDefined(); + expect(settings.hooks?.Stop).toBeDefined(); await access(path.join(repo, '.claude', 'commands', 'kgraph-compact.md')); await access(path.join(repo, '.claude', 'commands', 'kgraph-pack.md')); await access( @@ -137,7 +144,9 @@ describe('kgraph integrate', () => { const gemini = await readFile(path.join(repo, 'GEMINI.md'), 'utf8'); expect(gemini).toContain('Existing Gemini guidance'); expect(gemini).toContain('BEGIN KGRAPH gemini'); - expect(gemini).toContain('Every chat in this repository must use the correct KGraph command'); + expect(gemini).toContain( + 'Every chat in this repository must use the correct KGraph command', + ); expect(gemini).toContain('Command routing comes first'); expect(gemini).toContain('kgraph history ""'); expect(gemini).toContain('kgraph update'); diff --git a/tests/integration/session.test.ts b/tests/integration/session.test.ts index c8c28d5..e5f2282 100644 --- a/tests/integration/session.test.ts +++ b/tests/integration/session.test.ts @@ -1,8 +1,16 @@ import { readdir, readFile } from 'node:fs/promises'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { cleanupTempRepo, copyFixture, readJson, runCli } from '../fixtures/helpers.js'; -import type { SessionLedgerEntry, SessionState } from '../../src/types/session.js'; +import type { + SessionLedgerEntry, + SessionState, +} from '../../src/types/session.js'; +import { + cleanupTempRepo, + copyFixture, + readJson, + runCli, +} from '../fixtures/helpers.js'; describe('kgraph session', () => { it('records start/read/write/end events with agent attribution', async () => { @@ -11,10 +19,42 @@ describe('kgraph session', () => { await runCli(repo, ['init']); await runCli(repo, ['scan']); - expect((await runCli(repo, ['session', 'start', '--agent', 'codex'])).code).toBe(0); - expect((await runCli(repo, ['session', 'read', 'src/auth.ts', '--agent', 'codex'])).stdout).toContain('recorded read'); - expect((await runCli(repo, ['session', 'read', 'src/auth.ts', '--agent', 'codex'])).stdout).toContain('repeated'); - expect((await runCli(repo, ['session', 'write', 'src/auth.ts', '--agent', 'codex'])).stdout).toContain('recorded write'); + expect( + (await runCli(repo, ['session', 'start', '--agent', 'codex'])).code, + ).toBe(0); + expect( + ( + await runCli(repo, [ + 'session', + 'read', + 'src/auth.ts', + '--agent', + 'codex', + ]) + ).stdout, + ).toContain('recorded read'); + expect( + ( + await runCli(repo, [ + 'session', + 'read', + 'src/auth.ts', + '--agent', + 'codex', + ]) + ).stdout, + ).toContain('repeated'); + expect( + ( + await runCli(repo, [ + 'session', + 'write', + 'src/auth.ts', + '--agent', + 'codex', + ]) + ).stdout, + ).toContain('recorded write'); const status = await runCli(repo, ['session']); expect(status.stdout).toContain('KGraph Session'); @@ -25,13 +65,21 @@ describe('kgraph session', () => { const json = await runCli(repo, ['session', '--json']); expect(JSON.parse(json.stdout).repeatedReadCount).toBe(1); - expect((await runCli(repo, ['session', 'end', '--agent', 'codex'])).code).toBe(0); - const ledger = await readJson(repo, '.kgraph/sessions/ledger.json'); + expect( + (await runCli(repo, ['session', 'end', '--agent', 'codex'])).code, + ).toBe(0); + const ledger = await readJson( + repo, + '.kgraph/sessions/ledger.json', + ); expect(ledger[0].agent).toBe('codex'); expect(ledger[0].repeatedReadCount).toBe(1); expect((await runCli(repo, ['session', 'reset'])).code).toBe(0); - const state = await readJson(repo, '.kgraph/sessions/current.json').catch(() => undefined); + const state = await readJson( + repo, + '.kgraph/sessions/current.json', + ).catch(() => undefined); expect(state).toBeUndefined(); } finally { await cleanupTempRepo(repo); @@ -65,6 +113,8 @@ describe('kgraph session', () => { captureSource: 'automatic', }, ]); + expect(state.events[0].packUsedTokens).toBeGreaterThanOrEqual(0); + expect(state.events[0].packOmittedTokens).toBeGreaterThanOrEqual(0); const root = await runCli(repo, ['auth refresh', '--agent', 'codex']); expect(root.code).toBe(0); @@ -74,23 +124,24 @@ describe('kgraph session', () => { repo, '.kgraph/sessions/current.json', ); - expect(state.events.filter((event) => event.type === 'context')).toHaveLength(2); + expect( + state.events.filter((event) => event.type === 'context'), + ).toHaveLength(2); expect(state.events.some((event) => event.type === 'read')).toBe(false); const status = await runCli(repo, ['session']); expect(status.stdout).toContain('Active agents: codex'); + expect(status.stdout).toContain('Pack calls: 2'); expect(status.stdout).toContain('codex context [automatic]'); + expect(status.stdout).toContain('used:'); + expect(status.stdout).toContain('filtered:'); - expect((await runCli(repo, ['session', 'end', '--agent', 'codex'])).code).toBe(0); - const ledger = await readJson( - repo, - '.kgraph/sessions/ledger.json', + const sessionJson = JSON.parse( + (await runCli(repo, ['session', '--json'])).stdout, ); - expect(ledger[0]).toMatchObject({ - agent: 'codex', - readCount: 0, - writeCount: 0, - }); + expect(sessionJson.packCallCount).toBe(2); + expect(typeof sessionJson.totalPackUsedTokens).toBe('number'); + expect(typeof sessionJson.totalPackOmittedTokens).toBe('number'); } finally { await cleanupTempRepo(repo); } @@ -102,8 +153,20 @@ describe('kgraph session', () => { await runCli(repo, ['init']); await runCli(repo, ['scan']); await runCli(repo, ['session', 'start', '--agent', 'codex']); - await runCli(repo, ['session', 'read', 'src/auth.ts', '--agent', 'codex']); - await runCli(repo, ['session', 'write', 'src/session.ts', '--agent', 'codex']); + await runCli(repo, [ + 'session', + 'read', + 'src/auth.ts', + '--agent', + 'codex', + ]); + await runCli(repo, [ + 'session', + 'write', + 'src/session.ts', + '--agent', + 'codex', + ]); const result = await runCli(repo, [ 'session', @@ -152,7 +215,13 @@ describe('kgraph session', () => { ); await runCli(repo, ['session', 'start', '--agent', 'codex']); - await runCli(repo, ['session', 'read', 'src/auth.ts', '--agent', 'codex']); + await runCli(repo, [ + 'session', + 'read', + 'src/auth.ts', + '--agent', + 'codex', + ]); await runCli(repo, ['session', 'end', '--agent', 'codex']); await runCli(repo, ['session', 'start', '--agent', 'codex']);