From 548acca5c7285d9423666250aa522fa853f0a256 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Tue, 19 May 2026 16:02:09 +0200 Subject: [PATCH] feat(node-summary): AI-generated summaries with disk cache and LLM-gated invalidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a code node now streams an AI-generated overview into the summary sheet. Results are persisted to .graphy/summaries.json keyed by node id and reused on subsequent clicks. When the node's source or its caller/callee set has changed, a Haiku validator decides whether the cached prose is still accurate before regenerating — cosmetic edits keep the cache warm. A Refresh button in the sheet header forces a fresh generation bypassing cache and validator. With no Claude key configured, the body shows a settings CTA instead of any network calls. --- electron/main.cjs | 17 ++ electron/preload.cjs | 4 + electron/project-state.cjs | 48 ++++ src/modules/ai-chat/index.ts | 3 + src/modules/graph/components/graph-canvas.tsx | 28 +- .../components/node-summary-sheet.tsx | 68 +++-- .../components/summary-overview.tsx | 95 +++++++ .../node-summary/hooks/use-node-summary.ts | 239 ++++++++++++++++++ src/modules/node-summary/index.ts | 5 +- src/modules/node-summary/lib/build-prompts.ts | 114 +++++++++ src/modules/node-summary/lib/cache.ts | 24 ++ src/modules/node-summary/lib/fingerprint.ts | 34 +++ .../node-summary/lib/generate-summary.ts | 35 +++ src/modules/node-summary/lib/hash.ts | 7 + src/modules/node-summary/lib/mock-summary.ts | 73 ------ src/modules/node-summary/lib/relations.ts | 50 ++++ .../node-summary/lib/validate-cache.ts | 42 +++ src/modules/node-summary/types.ts | 20 +- src/shared/lib/desktop.ts | 17 ++ 19 files changed, 794 insertions(+), 129 deletions(-) create mode 100644 src/modules/node-summary/components/summary-overview.tsx create mode 100644 src/modules/node-summary/hooks/use-node-summary.ts create mode 100644 src/modules/node-summary/lib/build-prompts.ts create mode 100644 src/modules/node-summary/lib/cache.ts create mode 100644 src/modules/node-summary/lib/fingerprint.ts create mode 100644 src/modules/node-summary/lib/generate-summary.ts create mode 100644 src/modules/node-summary/lib/hash.ts delete mode 100644 src/modules/node-summary/lib/mock-summary.ts create mode 100644 src/modules/node-summary/lib/relations.ts create mode 100644 src/modules/node-summary/lib/validate-cache.ts diff --git a/electron/main.cjs b/electron/main.cjs index 9a2d5be..c77f60e 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -378,6 +378,23 @@ function registerIpc() { } }) + ipcMain.handle('summary:read', (_e, payload) => { + const nodeId = payload?.nodeId + if (!currentFolder || typeof nodeId !== 'string') return null + return projectState.readSummary(currentFolder, nodeId) + }) + + ipcMain.handle('summary:write', (_e, payload) => { + if (!currentFolder || !payload || typeof payload.nodeId !== 'string') return + projectState.writeSummary(currentFolder, payload) + }) + + ipcMain.handle('summary:delete', (_e, payload) => { + const nodeId = payload?.nodeId + if (!currentFolder || typeof nodeId !== 'string') return + projectState.deleteSummary(currentFolder, nodeId) + }) + ipcMain.handle('graphy:file-tree', async () => { if (!currentFolder) return null const root = currentFolder diff --git a/electron/preload.cjs b/electron/preload.cjs index 306d989..007e1c5 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -15,6 +15,10 @@ contextBridge.exposeInMainWorld('graphyDesktop', { reloadGraph: () => ipcRenderer.invoke('graphy:reload'), clearRecents: () => ipcRenderer.invoke('graphy:clear-recents'), cacheLayout: (layout) => ipcRenderer.invoke('graphy:cache-layout', layout), + readNodeSummary: (nodeId) => ipcRenderer.invoke('summary:read', { nodeId }), + writeNodeSummary: (payload) => ipcRenderer.invoke('summary:write', payload), + deleteNodeSummary: (nodeId) => + ipcRenderer.invoke('summary:delete', { nodeId }), onProject: (handler) => subscribe('project:set', handler), onGraph: (handler) => subscribe('graph:set', handler), readFunctionSource: (payload) => ipcRenderer.invoke('function:read', payload), diff --git a/electron/project-state.cjs b/electron/project-state.cjs index 4bad8c8..b1f350e 100644 --- a/electron/project-state.cjs +++ b/electron/project-state.cjs @@ -5,6 +5,7 @@ const path = require('node:path') const STATE_FILE = 'graphy-state.json' const CACHE_DIRNAME = '.graphy' const CACHE_FILENAME = 'cache.json' +const SUMMARIES_FILENAME = 'summaries.json' const MAX_RECENTS = 10 function statePath(app) { @@ -87,6 +88,50 @@ function writeCache(folder, graph, layout) { fs.writeFileSync(cacheFile(folder), JSON.stringify({ graph, layout })) } +function summariesFile(folder) { + return path.join(folder, CACHE_DIRNAME, SUMMARIES_FILENAME) +} + +function readSummariesFile(folder) { + try { + const raw = fs.readFileSync(summariesFile(folder), 'utf8') + const parsed = JSON.parse(raw) + if (!parsed || typeof parsed !== 'object') return { summaries: {} } + const summaries = + parsed.summaries && typeof parsed.summaries === 'object' + ? parsed.summaries + : {} + return { summaries } + } catch { + return { summaries: {} } + } +} + +function writeSummariesFile(folder, file) { + const dir = path.join(folder, CACHE_DIRNAME) + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(summariesFile(folder), JSON.stringify(file)) +} + +function readSummary(folder, nodeId) { + const { summaries } = readSummariesFile(folder) + return summaries[nodeId] ?? null +} + +function writeSummary(folder, payload) { + const file = readSummariesFile(folder) + file.summaries[payload.nodeId] = payload + writeSummariesFile(folder, file) +} + +function deleteSummary(folder, nodeId) { + const file = readSummariesFile(folder) + if (file.summaries[nodeId]) { + delete file.summaries[nodeId] + writeSummariesFile(folder, file) + } +} + module.exports = { read, recordFolder, @@ -94,4 +139,7 @@ module.exports = { pruneMissing, readCache, writeCache, + readSummary, + writeSummary, + deleteSummary, } diff --git a/src/modules/ai-chat/index.ts b/src/modules/ai-chat/index.ts index cc371f5..2b4060a 100644 --- a/src/modules/ai-chat/index.ts +++ b/src/modules/ai-chat/index.ts @@ -1,3 +1,6 @@ export { AiChatProvider } from './context/ai-chat-provider' export { AiChatPanel } from './components/ai-chat-panel' export { AiChatToggle } from './components/ai-chat-toggle' +export { readAiConfig } from './lib/storage' +export { CHAT_MODEL_API_ID, CHAT_MODELS, DEFAULT_CHAT_MODEL } from './types' +export type { AiProvider, ChatModel } from './types' diff --git a/src/modules/graph/components/graph-canvas.tsx b/src/modules/graph/components/graph-canvas.tsx index 864f831..e1b99cd 100644 --- a/src/modules/graph/components/graph-canvas.tsx +++ b/src/modules/graph/components/graph-canvas.tsx @@ -14,8 +14,7 @@ import { useProject } from '@/modules/graph/hooks/use-project' import { toXYFlow } from '@/modules/graph/lib/to-xyflow' import type { XYFlowGraph } from '@/modules/graph/lib/to-xyflow' import type { GraphNodeData } from '@/modules/graph/types' -import { MOCK_SUMMARY_TARGET, NodeSummarySheet } from '@/modules/node-summary' -import type { NodeSummaryTarget } from '@/modules/node-summary' +import { NodeSummarySheet } from '@/modules/node-summary' import { getDesktop } from '@/shared/lib/desktop' import { clearGraphFocus, useGraphFocusRequest } from '@/shared/lib/graph-focus' import { DiffBootstrap } from '@/modules/diff-viewer' @@ -40,9 +39,7 @@ export function GraphCanvas() { const [sheetTarget, setSheetTarget] = useState( null, ) - const [summaryTarget, setSummaryTarget] = useState( - null, - ) + const [summaryNodeId, setSummaryNodeId] = useState(null) const [expandedGroups, setExpandedGroups] = useState>( () => new Set(), ) @@ -67,22 +64,7 @@ export function GraphCanvas() { } pendingClickTimer.current = window.setTimeout(() => { pendingClickTimer.current = null - // TODO: replace mock body with a real summary fetched for this node. - setSummaryTarget({ - ...MOCK_SUMMARY_TARGET, - displayName: node.data.displayName, - type: node.data.type, - signature: node.data.signature, - file: node.data.file, - line: node.data.line, - facts: { - isAsync: node.data.isAsync, - isExported: node.data.isExported, - bodyLines: node.data.bodyLines, - inDegree: node.data.inDegree, - outDegree: node.data.outDegree, - }, - }) + setSummaryNodeId(node.id) }, 220) }, [], @@ -260,9 +242,9 @@ export function GraphCanvas() { onOpenChange={handleSheetOpenChange} /> { - if (!open) setSummaryTarget(null) + if (!open) setSummaryNodeId(null) }} /> diff --git a/src/modules/node-summary/components/node-summary-sheet.tsx b/src/modules/node-summary/components/node-summary-sheet.tsx index ba43223..7ab92c9 100644 --- a/src/modules/node-summary/components/node-summary-sheet.tsx +++ b/src/modules/node-summary/components/node-summary-sheet.tsx @@ -1,4 +1,4 @@ -import { FileCode, Sparkles } from 'lucide-react' +import { FileCode, RefreshCw, Sparkles } from 'lucide-react' import { Button } from '@/shared/ui/button' import { @@ -10,20 +10,23 @@ import { SheetTitle, } from '@/shared/ui/sheet' -import type { NodeSummaryTarget } from '../types' +import { useNodeSummary } from '../hooks/use-node-summary' import { SummaryFacts } from './summary-facts' +import { SummaryOverview } from './summary-overview' import { SummaryRelationsList } from './summary-relations-list' type NodeSummarySheetProps = { - target: NodeSummaryTarget | null + nodeId: string | null onOpenChange: (open: boolean) => void } export function NodeSummarySheet({ - target, + nodeId, onOpenChange, }: NodeSummarySheetProps) { - const open = target !== null + const { header, callers, callees, overview, status, error, refresh } = + useNodeSummary(nodeId) + const open = nodeId !== null return ( @@ -32,27 +35,42 @@ export function NodeSummarySheet({ className="flex w-full flex-col gap-0 p-0 data-[side=right]:sm:max-w-[640px]" > -
- - Node summary +
+
+ + Node summary +
+
- {target?.displayName ?? 'Node'} + {header?.displayName ?? 'Node'} - {target ? `${target.file}:${target.line}` : ''} + {header ? `${header.file}:${header.line}` : ''} - {target && ( + {header && (
- + - {target.signature && ( + {header.signature && (
- {target.displayName} + {header.displayName} - {target.signature} + {header.signature}
)} @@ -61,28 +79,24 @@ export function NodeSummarySheet({

Overview

-
- {target.overview.map((paragraph, index) => ( -

- {paragraph} -

- ))} -
+
)} diff --git a/src/modules/node-summary/components/summary-overview.tsx b/src/modules/node-summary/components/summary-overview.tsx new file mode 100644 index 0000000..aae31b4 --- /dev/null +++ b/src/modules/node-summary/components/summary-overview.tsx @@ -0,0 +1,95 @@ +import { openSettings } from '@/shared/lib/settings-open' +import { Button } from '@/shared/ui/button' +import { Skeleton } from '@/shared/ui/skeleton' + +import type { SummaryStatus } from '../types' + +type SummaryOverviewProps = { + paragraphs: string[] + status: SummaryStatus + error: string | null + onRetry: () => void +} + +export function SummaryOverview({ + paragraphs, + status, + error, + onRetry, +}: SummaryOverviewProps) { + if (status === 'no-key') { + return + } + + if (status === 'error') { + return ( +
+ {error ?? 'Failed to generate summary.'} +
+ +
+
+ ) + } + + if (status === 'loading' || status === 'validating') { + return + } + + const showCursor = status === 'streaming' + return ( +
+ {paragraphs.length === 0 && showCursor && ( + + )} + {paragraphs.map((paragraph, index) => ( +

+ {paragraph} + {showCursor && index === paragraphs.length - 1 && ( + + ▍ + + )} +

+ ))} +
+ ) +} + +function LoadingState({ label }: { label: string }) { + return ( +
+
+ {label} +
+ + + +
+ ) +} + +function NoKeyState() { + return ( +
+ + Add a Claude API key in settings to generate node summaries. + +
+ +
+
+ ) +} + +function statusLabel(status: SummaryStatus): string { + if (status === 'validating') return 'Checking cached summary…' + return 'Generating summary…' +} diff --git a/src/modules/node-summary/hooks/use-node-summary.ts b/src/modules/node-summary/hooks/use-node-summary.ts new file mode 100644 index 0000000..db32e0d --- /dev/null +++ b/src/modules/node-summary/hooks/use-node-summary.ts @@ -0,0 +1,239 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { CHAT_MODEL_API_ID, readAiConfig } from '@/modules/ai-chat' +import { useGraph } from '@/modules/graph' +import { getDesktop } from '@/shared/lib/desktop' + +import type { + CachedSummary, + NodeSummaryHeader, + NodeSummaryRelation, + SummaryStatus, +} from '../types' +import { buildSummaryPrompt, buildValidatorPrompt } from '../lib/build-prompts' +import { readCachedSummary, writeCachedSummary } from '../lib/cache' +import { computeFingerprint, fingerprintMatches } from '../lib/fingerprint' +import { streamSummary } from '../lib/generate-summary' +import { getNeighbors, getNodeMetadata, toHeader } from '../lib/relations' +import { validateCached } from '../lib/validate-cache' + +export type UseNodeSummary = { + header: NodeSummaryHeader | null + callers: NodeSummaryRelation[] + callees: NodeSummaryRelation[] + overview: string[] + status: SummaryStatus + error: string | null + refresh: () => void +} + +export function useNodeSummary(nodeId: string | null): UseNodeSummary { + const { graph, folder } = useGraph() + const [overview, setOverview] = useState([]) + const [status, setStatus] = useState('idle') + const [error, setError] = useState(null) + const [runToken, setRunToken] = useState(0) + const forceNextRef = useRef(false) + + const derived = useMemo(() => { + if (!graph || !nodeId) { + return { + header: null as NodeSummaryHeader | null, + callers: [] as NodeSummaryRelation[], + callees: [] as NodeSummaryRelation[], + } + } + const node = getNodeMetadata(graph, nodeId) + if (!node) return { header: null, callers: [], callees: [] } + const header = toHeader(node) + const { callers, callees } = getNeighbors(graph, nodeId) + return { header, callers, callees } + }, [graph, nodeId]) + + useEffect(() => { + if (!nodeId) { + setStatus('idle') + setOverview([]) + setError(null) + return + } + if (!graph || !folder || !derived.header) { + setStatus('loading') + setOverview([]) + setError(null) + return + } + + const controller = new AbortController() + const force = forceNextRef.current + forceNextRef.current = false + + void resolve({ + signal: controller.signal, + nodeId, + folder, + header: derived.header, + callers: derived.callers, + callees: derived.callees, + force, + setStatus, + setOverview, + setError, + }) + + return () => controller.abort() + // runToken is intentionally a dep so refresh() forces a re-run. + }, [nodeId, graph, folder, derived, runToken]) + + const refresh = useCallback(() => { + forceNextRef.current = true + setRunToken((n) => n + 1) + }, []) + + return { + header: derived.header, + callers: derived.callers, + callees: derived.callees, + overview, + status, + error, + refresh, + } +} + +async function resolve({ + signal, + nodeId, + folder, + header, + callers, + callees, + force, + setStatus, + setOverview, + setError, +}: { + signal: AbortSignal + nodeId: string + folder: string + header: NodeSummaryHeader + callers: NodeSummaryRelation[] + callees: NodeSummaryRelation[] + force: boolean + setStatus: (s: SummaryStatus) => void + setOverview: (paragraphs: string[]) => void + setError: (msg: string | null) => void +}): Promise { + setError(null) + setOverview([]) + setStatus('loading') + + const config = readAiConfig() + const apiKey = config.keys.claude + if (!apiKey) { + setStatus('no-key') + return + } + const summaryModel = CHAT_MODEL_API_ID[config.defaultModel] + + const desktop = getDesktop() + if (!desktop?.readFunctionSource) { + setError('Summaries require the Graphy desktop app.') + setStatus('error') + return + } + + try { + const { source } = await desktop.readFunctionSource({ + root: folder, + file: header.file, + startLine: header.line, + endLine: header.endLine, + }) + if (isAborted(signal)) return + + const currentFingerprint = await computeFingerprint({ + source, + callers, + callees, + }) + if (isAborted(signal)) return + + const cached = force ? null : await readCachedSummary(nodeId) + if (isAborted(signal)) return + + if (cached) { + if (fingerprintMatches(cached.fingerprint, currentFingerprint)) { + setOverview(cached.overview) + setStatus('ready') + return + } + + setStatus('validating') + const { system, user } = buildValidatorPrompt({ + oldOverview: cached.overview, + newSource: source, + oldFingerprint: cached.fingerprint, + newFingerprint: currentFingerprint, + }) + const verdict = await validateCached({ apiKey, system, user, signal }) + if (isAborted(signal)) return + + if (verdict.stillValid) { + setOverview(cached.overview) + setStatus('ready') + await writeCachedSummary({ + ...cached, + fingerprint: currentFingerprint, + }) + return + } + } + + setStatus('streaming') + const { system, user } = buildSummaryPrompt({ + header, + source, + callers, + callees, + }) + let final: string[] = [] + for await (const accumulated of streamSummary({ + apiKey, + model: summaryModel, + system, + user, + signal, + })) { + if (isAborted(signal)) return + final = splitParagraphs(accumulated) + setOverview(final) + } + if (isAborted(signal)) return + + setStatus('ready') + const payload: CachedSummary = { + nodeId, + overview: final, + fingerprint: currentFingerprint, + model: summaryModel, + generatedAt: Date.now(), + } + await writeCachedSummary(payload) + } catch (err) { + if (isAborted(signal)) return + setError(err instanceof Error ? err.message : String(err)) + setStatus('error') + } +} + +function splitParagraphs(text: string): string[] { + return text + .split(/\n{2,}/) + .map((p) => p.trim()) + .filter((p) => p.length > 0) +} + +function isAborted(signal: AbortSignal): boolean { + return signal.aborted +} diff --git a/src/modules/node-summary/index.ts b/src/modules/node-summary/index.ts index e5073c5..b70c38c 100644 --- a/src/modules/node-summary/index.ts +++ b/src/modules/node-summary/index.ts @@ -1,7 +1,8 @@ export { NodeSummarySheet } from './components/node-summary-sheet' -export { MOCK_SUMMARY_TARGET } from './lib/mock-summary' export type { + CachedSummary, NodeSummaryFacts, + NodeSummaryHeader, NodeSummaryRelation, - NodeSummaryTarget, + SummaryStatus, } from './types' diff --git a/src/modules/node-summary/lib/build-prompts.ts b/src/modules/node-summary/lib/build-prompts.ts new file mode 100644 index 0000000..946c617 --- /dev/null +++ b/src/modules/node-summary/lib/build-prompts.ts @@ -0,0 +1,114 @@ +import type { + CachedSummaryFingerprint, + NodeSummaryHeader, + NodeSummaryRelation, +} from '../types' + +const SUMMARY_SYSTEM = `You explain code from a project graph to engineers. Given one node's source, its callers, and what it calls, write 2 to 3 short paragraphs (2-4 sentences each). + +Cover, in order: +1. What this code does, in plain language. +2. Its role in the larger codebase - how it is used by callers and how it depends on its callees. + +Rules: +- Output plain text. Paragraphs separated by a blank line. No markdown headings, no bullet lists, no code fences. +- Reference callers and callees by name only when it clarifies the role. Do not enumerate them all. +- Do not restate the signature or file path. Skip filler ("This function..."). +- Stay concrete. If the code is short or trivial, the summary should be too.` + +export function buildSummaryPrompt({ + header, + source, + callers, + callees, +}: { + header: NodeSummaryHeader + source: string + callers: NodeSummaryRelation[] + callees: NodeSummaryRelation[] +}): { system: string; user: string } { + const lines: string[] = [] + lines.push(`Node: ${header.displayName} (${header.type})`) + lines.push(`Location: ${header.file}:${header.line}`) + lines.push(`Signature: ${header.signature || '(none)'}`) + lines.push('') + lines.push('Source:') + lines.push('```') + lines.push(source) + lines.push('```') + lines.push('') + lines.push('Callers (nodes that invoke this):') + lines.push(formatRelations(callers)) + lines.push('') + lines.push('Callees (nodes invoked by this):') + lines.push(formatRelations(callees)) + return { system: SUMMARY_SYSTEM, user: lines.join('\n') } +} + +const VALIDATOR_SYSTEM = `You judge whether a saved summary of code is still accurate after edits. Compare the saved summary to the current source and neighborhood. + +Return JSON: { "stillValid": boolean, "reason": "" }. + +- stillValid: true if every claim in the saved summary still describes the current code. Cosmetic changes (whitespace, renamed locals, refactors that preserve behavior) do not invalidate. +- stillValid: false if any concrete claim is now wrong or missing, or the role of the code has shifted in a way a reader would care about. +- Be strict but not pedantic. Reader value drives the decision.` + +export function buildValidatorPrompt({ + oldOverview, + newSource, + oldFingerprint, + newFingerprint, +}: { + oldOverview: string[] + newSource: string + oldFingerprint: CachedSummaryFingerprint + newFingerprint: CachedSummaryFingerprint +}): { system: string; user: string } { + const callerDiff = diffSets( + oldFingerprint.callerIds, + newFingerprint.callerIds, + ) + const calleeDiff = diffSets( + oldFingerprint.calleeIds, + newFingerprint.calleeIds, + ) + + const lines: string[] = [] + lines.push('Saved summary:') + for (const paragraph of oldOverview) { + lines.push(paragraph) + lines.push('') + } + lines.push('Current source:') + lines.push('```') + lines.push(newSource) + lines.push('```') + lines.push('') + lines.push('Caller changes:') + lines.push(` added: ${callerDiff.added.join(', ') || '(none)'}`) + lines.push(` removed: ${callerDiff.removed.join(', ') || '(none)'}`) + lines.push('Callee changes:') + lines.push(` added: ${calleeDiff.added.join(', ') || '(none)'}`) + lines.push(` removed: ${calleeDiff.removed.join(', ') || '(none)'}`) + + return { system: VALIDATOR_SYSTEM, user: lines.join('\n') } +} + +function formatRelations(items: NodeSummaryRelation[]): string { + if (items.length === 0) return ' (none)' + return items + .map((r) => ` - ${r.displayName} (${r.type}) at ${r.file}:${r.line}`) + .join('\n') +} + +function diffSets( + oldIds: string[], + newIds: string[], +): { added: string[]; removed: string[] } { + const oldSet = new Set(oldIds) + const newSet = new Set(newIds) + return { + added: newIds.filter((id) => !oldSet.has(id)), + removed: oldIds.filter((id) => !newSet.has(id)), + } +} diff --git a/src/modules/node-summary/lib/cache.ts b/src/modules/node-summary/lib/cache.ts new file mode 100644 index 0000000..f68ca90 --- /dev/null +++ b/src/modules/node-summary/lib/cache.ts @@ -0,0 +1,24 @@ +import { getDesktop } from '@/shared/lib/desktop' +import type { CachedSummary } from '@/shared/lib/desktop' + +export async function readCachedSummary( + nodeId: string, +): Promise { + const desktop = getDesktop() + if (!desktop?.readNodeSummary) return null + return desktop.readNodeSummary(nodeId) +} + +export async function writeCachedSummary( + payload: CachedSummary, +): Promise { + const desktop = getDesktop() + if (!desktop?.writeNodeSummary) return + await desktop.writeNodeSummary(payload) +} + +export async function deleteCachedSummary(nodeId: string): Promise { + const desktop = getDesktop() + if (!desktop?.deleteNodeSummary) return + await desktop.deleteNodeSummary(nodeId) +} diff --git a/src/modules/node-summary/lib/fingerprint.ts b/src/modules/node-summary/lib/fingerprint.ts new file mode 100644 index 0000000..3964e23 --- /dev/null +++ b/src/modules/node-summary/lib/fingerprint.ts @@ -0,0 +1,34 @@ +import type { CachedSummaryFingerprint, NodeSummaryRelation } from '../types' +import { sha256Hex } from './hash' + +export async function computeFingerprint({ + source, + callers, + callees, +}: { + source: string + callers: NodeSummaryRelation[] + callees: NodeSummaryRelation[] +}): Promise { + return { + sourceHash: await sha256Hex(source), + callerIds: callers.map((c) => c.id).sort(), + calleeIds: callees.map((c) => c.id).sort(), + } +} + +export function fingerprintMatches( + a: CachedSummaryFingerprint, + b: CachedSummaryFingerprint, +): boolean { + if (a.sourceHash !== b.sourceHash) return false + if (!arrayEq(a.callerIds, b.callerIds)) return false + if (!arrayEq(a.calleeIds, b.calleeIds)) return false + return true +} + +function arrayEq(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false + return true +} diff --git a/src/modules/node-summary/lib/generate-summary.ts b/src/modules/node-summary/lib/generate-summary.ts new file mode 100644 index 0000000..da39cf3 --- /dev/null +++ b/src/modules/node-summary/lib/generate-summary.ts @@ -0,0 +1,35 @@ +import type { AiMessage } from '@/modules/ai' +import { ClaudeService } from '@/modules/ai' + +export async function* streamSummary({ + apiKey, + model, + system, + user, + signal, +}: { + apiKey: string + model: string + system: string + user: string + signal: AbortSignal +}): AsyncIterable { + const service = new ClaudeService({ apiKey, dangerouslyAllowBrowser: true }) + const messages: AiMessage[] = [ + { role: 'system', content: system }, + { role: 'user', content: user }, + ] + let accumulated = '' + for await (const chunk of service.stream({ + messages, + model, + maxTokens: 700, + signal, + })) { + if (chunk.delta) { + accumulated += chunk.delta + yield accumulated + } + if (chunk.done) return + } +} diff --git a/src/modules/node-summary/lib/hash.ts b/src/modules/node-summary/lib/hash.ts new file mode 100644 index 0000000..839b255 --- /dev/null +++ b/src/modules/node-summary/lib/hash.ts @@ -0,0 +1,7 @@ +export async function sha256Hex(text: string): Promise { + const bytes = new TextEncoder().encode(text) + const digest = await crypto.subtle.digest('SHA-256', bytes) + return Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') +} diff --git a/src/modules/node-summary/lib/mock-summary.ts b/src/modules/node-summary/lib/mock-summary.ts deleted file mode 100644 index 02d281c..0000000 --- a/src/modules/node-summary/lib/mock-summary.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { NodeSummaryTarget } from '../types' - -export const MOCK_SUMMARY_TARGET: NodeSummaryTarget = { - displayName: 'useGraph', - type: 'hook', - signature: '(): { graph, folder, loading, error, layout }', - file: 'src/modules/graph/hooks/use-graph.ts', - line: 18, - facts: { - isAsync: false, - isExported: true, - bodyLines: 64, - inDegree: 3, - outDegree: 5, - }, - overview: [ - 'Central hook that exposes the parsed project graph to the canvas. It subscribes to the active folder, requests an indexing pass from the desktop parser, and returns the resulting graph along with loading and error state.', - 'Most graph-rendering components consume this hook directly rather than maintaining their own copy of the graph. Layout is read from the cached .graphy/ directory when available and recomputed otherwise.', - ], - callers: [ - { - id: 'graph-canvas', - displayName: 'GraphCanvas', - type: 'component', - file: 'src/modules/graph/components/graph-canvas.tsx', - line: 27, - }, - { - id: 'graph-legend', - displayName: 'GraphLegend', - type: 'component', - file: 'src/modules/graph/components/graph-legend.tsx', - line: 14, - }, - { - id: 'use-graph-stats', - displayName: 'useGraphStats', - type: 'hook', - file: 'src/modules/graph/hooks/use-graph-stats.ts', - line: 9, - }, - ], - callees: [ - { - id: 'use-project', - displayName: 'useProject', - type: 'hook', - file: 'src/modules/graph/hooks/use-project.ts', - line: 22, - }, - { - id: 'get-desktop', - displayName: 'getDesktop', - type: 'function', - file: 'src/shared/lib/desktop.ts', - line: 5, - }, - { - id: 'parse-project', - displayName: 'parseProject', - type: 'function', - file: 'src/modules/parser/index.ts', - line: 84, - }, - { - id: 'validate-graph', - displayName: 'validateGraph', - type: 'function', - file: 'src/modules/parser/core/schema.ts', - line: 41, - }, - ], -} diff --git a/src/modules/node-summary/lib/relations.ts b/src/modules/node-summary/lib/relations.ts new file mode 100644 index 0000000..5843e9c --- /dev/null +++ b/src/modules/node-summary/lib/relations.ts @@ -0,0 +1,50 @@ +import { inspectClasses } from '@/modules/parser' +import type { Graph, GraphNode } from '@/modules/parser' + +import type { NodeSummaryHeader, NodeSummaryRelation } from '../types' + +export function getNodeMetadata( + graph: Graph, + nodeId: string, +): GraphNode | null { + return graph.nodes.find((n) => n.id === nodeId) ?? null +} + +export function toHeader(node: GraphNode): NodeSummaryHeader { + return { + displayName: node.name, + type: node.type, + signature: node.signature, + file: node.file, + line: node.line, + endLine: node.endLine, + facts: { + isAsync: node.isAsync, + isExported: node.isExported, + bodyLines: node.bodyLines, + inDegree: node.inDegree, + outDegree: node.outDegree, + }, + } +} + +function toRelation(node: GraphNode): NodeSummaryRelation { + return { + id: node.id, + displayName: node.name, + type: node.type, + file: node.file, + line: node.line, + } +} + +export function getNeighbors( + graph: Graph, + nodeId: string, +): { callers: NodeSummaryRelation[]; callees: NodeSummaryRelation[] } { + const inspector = inspectClasses(graph) + return { + callers: inspector.callersOf(nodeId).map(toRelation), + callees: inspector.calleesOf(nodeId).map(toRelation), + } +} diff --git a/src/modules/node-summary/lib/validate-cache.ts b/src/modules/node-summary/lib/validate-cache.ts new file mode 100644 index 0000000..e69de1d --- /dev/null +++ b/src/modules/node-summary/lib/validate-cache.ts @@ -0,0 +1,42 @@ +import type { AiMessage } from '@/modules/ai' +import { ClaudeService } from '@/modules/ai' + +export const VALIDATOR_MODEL = 'claude-haiku-4-5-20251001' + +const VALIDATOR_SCHEMA = { + type: 'object', + properties: { + stillValid: { type: 'boolean' }, + reason: { type: 'string' }, + }, + required: ['stillValid', 'reason'], + additionalProperties: false, +} as const + +export type ValidatorResult = { stillValid: boolean; reason: string } + +export async function validateCached({ + apiKey, + system, + user, + signal, +}: { + apiKey: string + system: string + user: string + signal: AbortSignal +}): Promise { + const service = new ClaudeService({ apiKey, dangerouslyAllowBrowser: true }) + const messages: AiMessage[] = [ + { role: 'system', content: system }, + { role: 'user', content: user }, + ] + const { data } = await service.generateObject({ + messages, + model: VALIDATOR_MODEL, + maxTokens: 200, + schema: VALIDATOR_SCHEMA, + signal, + }) + return data +} diff --git a/src/modules/node-summary/types.ts b/src/modules/node-summary/types.ts index 9c6b290..0e53630 100644 --- a/src/modules/node-summary/types.ts +++ b/src/modules/node-summary/types.ts @@ -16,14 +16,26 @@ export type NodeSummaryRelation = { line: number } -export type NodeSummaryTarget = { +export type NodeSummaryHeader = { displayName: string type: NodeType signature: string file: string line: number + endLine: number facts: NodeSummaryFacts - overview: string[] - callers: NodeSummaryRelation[] - callees: NodeSummaryRelation[] } + +export type SummaryStatus = + | 'idle' + | 'loading' + | 'validating' + | 'streaming' + | 'ready' + | 'error' + | 'no-key' + +export type { + CachedSummary, + CachedSummaryFingerprint, +} from '@/shared/lib/desktop' diff --git a/src/shared/lib/desktop.ts b/src/shared/lib/desktop.ts index c4497c1..ae11bfe 100644 --- a/src/shared/lib/desktop.ts +++ b/src/shared/lib/desktop.ts @@ -221,6 +221,20 @@ export type WebFetchResult = | { ok: true; contentType: string; body: string; truncated: boolean } | { ok: false; reason: string } +export interface CachedSummaryFingerprint { + sourceHash: string + callerIds: string[] + calleeIds: string[] +} + +export interface CachedSummary { + nodeId: string + overview: string[] + fingerprint: CachedSummaryFingerprint + model: string + generatedAt: number +} + export interface GraphyDesktop { platform: NodeJS.Platform getInitialState: () => Promise @@ -230,6 +244,9 @@ export interface GraphyDesktop { reloadGraph: () => Promise clearRecents: () => Promise cacheLayout: (layout: unknown) => Promise + readNodeSummary: (nodeId: string) => Promise + writeNodeSummary: (payload: CachedSummary) => Promise + deleteNodeSummary: (nodeId: string) => Promise onProject: (handler: (payload: ProjectPayload) => void) => () => void onGraph: (handler: (payload: GraphPayload) => void) => () => void readFunctionSource: (