Skip to content

Commit 62552d8

Browse files
committed
feat: add summary modes, cache and heuristic support
Add configurable summary mode (llm, heuristic, auto) with heuristic as default. Implement heuristic file/module summarization and traversal candidate selection. Add disk-persistent TraversalCache for query result caching, and a noop LLM client for offline workflows. Update IndexManager, TreeBuilder and CLI commands to support new modes and caching layers. Add summary cache persistence to FileSystemIndexStore, improve default summaries with accurate language detection, and streamline index command by removing unnecessary early API key validation.
1 parent 396f3da commit 62552d8

12 files changed

Lines changed: 349 additions & 45 deletions

File tree

packages/cli/src/commands/index.ts

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44

55
import type { Command } from "commander"
66
import * as path from "path"
7-
import { type CodeIndexConfig, loadConfig, resolveApiKey } from "../config.js"
8-
import { createLLMClient, createIndexManager } from "../createServices.js"
7+
import { type CodeIndexConfig, loadConfig } from "../config.js"
8+
import { createIndexManager, createLLMClient, createNoopLLMClient } from "../createServices.js"
99

1010
export function registerIndexCommand(program: Command): void {
1111
program
@@ -30,22 +30,14 @@ export function registerIndexCommand(program: Command): void {
3030
const config = loadConfig(projectRoot, overrides)
3131

3232

33-
// Validate API key sớm
34-
let apiKey: string
35-
try {
36-
apiKey = resolveApiKey(config)
37-
} catch (err) {
38-
console.error(`Error: ${(err as Error).message}`)
39-
process.exit(1)
40-
}
41-
4233
console.log(`📁 Indexing: ${projectRoot}`)
4334
console.log(`🤖 Provider: ${config.provider} / ${config.model}`)
4435
console.log(`📂 Index dir: ${config.indexDir}`)
36+
console.log(`🧾 Summary mode: ${config.summaryMode ?? "auto"}`)
4537
console.log("")
4638

4739
try {
48-
const llm = createLLMClient({ ...config, apiKey })
40+
const llm = (config.summaryMode ?? "auto") === "heuristic" ? createNoopLLMClient() : createLLMClient(config)
4941
const manager = await createIndexManager(projectRoot, config, llm)
5042
const supportedExts = manager.getSupportedExtensionsList()
5143
console.log(`🔌 Adapters: ${supportedExts.join(", ")}`)

packages/cli/src/commands/query.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { Command } from "commander"
66
import * as path from "path"
77
import { loadConfig } from "../config.js"
88
import { createLLMClient, createIndexManager } from "../createServices.js"
9-
import { FileSystemIndexStore, Retriever } from "@codeindex/core"
9+
import { FileSystemIndexStore, Retriever, TraversalCache } from "@codeindex/core"
1010

1111
export function registerQueryCommand(program: Command): void {
1212
program
@@ -38,8 +38,12 @@ export function registerQueryCommand(program: Command): void {
3838

3939
try {
4040
const llm = createLLMClient(config)
41+
const cache = new TraversalCache({
42+
persistencePath: path.join(projectRoot, config.indexDir, "traversal-cache.json"),
43+
})
4144
const retriever = new Retriever({
4245
llmClient: llm,
46+
cache,
4347
config: {
4448
maxOutputTokens: parseInt(options["maxTokens"] as string ?? "4000"),
4549
expandDeps: options["deps"] !== false,

packages/cli/src/commands/update.ts

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44

55
import type { Command } from "commander"
66
import * as path from "path"
7-
import { type CodeIndexConfig, loadConfig, resolveApiKey } from "../config.js"
8-
import { createLLMClient, createIndexManager } from "../createServices.js"
7+
import { type CodeIndexConfig, loadConfig } from "../config.js"
8+
import { createIndexManager, createLLMClient, createNoopLLMClient } from "../createServices.js"
99

1010
export function registerUpdateCommand(program: Command): void {
1111
program
@@ -23,16 +23,8 @@ export function registerUpdateCommand(program: Command): void {
2323
const config = loadConfig(projectRoot, overrides)
2424

2525

26-
let apiKey: string
2726
try {
28-
apiKey = resolveApiKey(config)
29-
} catch (err) {
30-
console.error(`Error: ${(err as Error).message}`)
31-
process.exit(1)
32-
}
33-
34-
try {
35-
const llm = createLLMClient({ ...config, apiKey })
27+
const llm = (config.summaryMode ?? "auto") === "heuristic" ? createNoopLLMClient() : createLLMClient(config)
3628
const manager = await createIndexManager(projectRoot, config, llm)
3729
const result = await manager.update()
3830

packages/cli/src/config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export interface CodeIndexConfig {
2626
indexDir: string
2727
/** Project name hiển thị trong index */
2828
projectName?: string
29+
summaryMode?: "llm" | "heuristic" | "auto"
2930
/** Verbose logging */
3031
verbose: boolean
3132
/** HTTP server API key (optional). If set, /query, /status, /update require auth. */
@@ -57,6 +58,7 @@ const DEFAULT_GLOBAL_CONFIG_DIR = path.join(os.homedir(), ".codeindex")
5758
const PROJECT_LOCAL_CONFIG_KEYS: Array<keyof CodeIndexConfig> = [
5859
"indexDir",
5960
"projectName",
61+
"summaryMode",
6062
"verbose",
6163
"serverApiKey",
6264
"serverCorsOrigin",
@@ -290,6 +292,7 @@ export function loadConfig(
290292
model: "gpt-4o",
291293
apiKey: "",
292294
indexDir: ".index",
295+
summaryMode: "heuristic",
293296
verbose: false,
294297
}
295298

@@ -375,6 +378,7 @@ export function inspectConfig(
375378
model: "gpt-4o",
376379
apiKey: "",
377380
indexDir: ".index",
381+
summaryMode: "heuristic",
378382
verbose: false,
379383
}
380384

@@ -383,6 +387,7 @@ export function inspectConfig(
383387
model: { source: "default", location: "built-in defaults" },
384388
apiKey: { source: "default", location: "built-in defaults" },
385389
indexDir: { source: "default", location: "built-in defaults" },
390+
summaryMode: { source: "default", location: "built-in defaults" },
386391
verbose: { source: "default", location: "built-in defaults" },
387392
}
388393

packages/cli/src/createServices.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,14 @@ export function createLLMClient(config: CodeIndexConfig): LLMClient {
7878
}
7979
}
8080

81+
export function createNoopLLMClient(): LLMClient {
82+
return {
83+
complete: async () => {
84+
throw new Error("LLM is disabled")
85+
},
86+
}
87+
}
88+
8189

8290
export async function createIndexManager(
8391
projectRoot: string,
@@ -93,5 +101,6 @@ export async function createIndexManager(
93101
indexDir: config.indexDir,
94102
verbose: config.verbose,
95103
...(config.projectName !== undefined && { projectName: config.projectName }),
104+
...(config.summaryMode !== undefined && { summaryMode: config.summaryMode }),
96105
})
97106
}

packages/cli/src/server/HttpServer.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export class HttpServer {
3131
private readonly config: CodeIndexConfig
3232
private readonly llmClient: LLMClient
3333
private server: http.Server | null = null
34-
private readonly traversalCache = new TraversalCache()
34+
private readonly traversalCache: TraversalCache
3535
private readonly serverApiKey: string | undefined
3636
private readonly corsOrigin: string
3737
private readonly maxBodyBytes: number
@@ -44,6 +44,9 @@ export class HttpServer {
4444
this.projectRoot = options.projectRoot
4545
this.config = options.config
4646
this.llmClient = options.llmClient
47+
this.traversalCache = new TraversalCache({
48+
persistencePath: path.join(this.projectRoot, this.config.indexDir, "traversal-cache.json"),
49+
})
4750
this.serverApiKey = options.config.serverApiKey
4851
this.corsOrigin = options.config.serverCorsOrigin ?? "*"
4952
this.maxBodyBytes = options.config.serverMaxBodyBytes ?? 1024 * 1024

packages/core/src/llm/SummaryGenerator.ts

Lines changed: 148 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import type { LLMClient } from "../types/LLMClient.js"
77
import type { ParsedFile, RawSymbol } from "../types/RawSymbol.js"
88

9+
export type SummaryMode = "llm" | "heuristic" | "auto"
10+
911
export interface FileSummaryResult {
1012
relativePath: string
1113
shortSummary: string
@@ -17,20 +19,61 @@ export interface ModuleSummaryResult {
1719
shortSummary: string
1820
}
1921

22+
export interface SummaryCacheEntry {
23+
gitHash: string
24+
shortSummary: string
25+
detailedSummary: string
26+
updatedAt: number
27+
}
28+
29+
export type SummaryCache = Record<string, SummaryCacheEntry>
30+
2031
export class SummaryGenerator {
21-
constructor(private readonly llm: LLMClient) {}
32+
private mode: SummaryMode
33+
34+
constructor(
35+
private readonly llm: LLMClient,
36+
options: { mode?: SummaryMode } = {}
37+
) {
38+
this.mode = options.mode ?? "auto"
39+
}
40+
41+
setMode(mode: SummaryMode): void {
42+
this.mode = mode
43+
}
44+
45+
async generateFileSummary(file: ParsedFile): Promise<FileSummaryResult> {
46+
if (this.mode === "heuristic") {
47+
return {
48+
relativePath: file.relativePath,
49+
shortSummary: this.heuristicShortSummary(file),
50+
detailedSummary: this.heuristicDetailedSummary(file),
51+
}
52+
}
53+
54+
try {
55+
return await this.generateFileSummaryLLM(file)
56+
} catch {
57+
return {
58+
relativePath: file.relativePath,
59+
shortSummary: this.heuristicShortSummary(file),
60+
detailedSummary: this.heuristicDetailedSummary(file),
61+
}
62+
}
63+
}
2264

2365
/**
2466
* Generate summary cho một file từ symbols của nó.
2567
* Feed chỉ signatures + docComments — không feed full source.
2668
*/
27-
async generateFileSummary(file: ParsedFile): Promise<FileSummaryResult> {
69+
private async generateFileSummaryLLM(file: ParsedFile): Promise<FileSummaryResult> {
2870
const signaturesText = this.buildSignaturesText(file.symbols)
2971
const exportsList = file.exports.join(", ")
3072

31-
const prompt = `You are analyzing a TypeScript file to create a concise index entry.
73+
const prompt = `You are analyzing a source file to create a concise index entry.
3274
3375
File: ${file.relativePath}
76+
Language: ${file.language}
3477
Exports: ${exportsList || "none"}
3578
3679
Symbols (signatures only):
@@ -50,8 +93,8 @@ Respond with ONLY a JSON object in this exact format, no markdown:
5093
})
5194

5295
const parsed = this.parseJsonResponse(response.content, {
53-
short: `${file.relativePath} — TypeScript module`,
54-
detailed: `Contains: ${exportsList}`,
96+
short: this.heuristicShortSummary(file),
97+
detailed: this.heuristicDetailedSummary(file),
5598
})
5699

57100
return {
@@ -61,30 +104,112 @@ Respond with ONLY a JSON object in this exact format, no markdown:
61104
}
62105
}
63106

107+
private heuristicShortSummary(file: ParsedFile): string {
108+
const exported = file.symbols.filter((s) => s.isExported).map((s) => s.name)
109+
const top = (exported.length > 0 ? exported : file.symbols.map((s) => s.name)).slice(0, 5)
110+
const suffix = top.length > 0 ? ` (${top.join(", ")})` : ""
111+
return `${file.relativePath}${file.language} file with ${file.symbols.length} symbols${suffix}`
112+
}
113+
114+
private heuristicDetailedSummary(file: ParsedFile): string {
115+
const kinds = new Map<string, number>()
116+
for (const s of file.symbols) {
117+
kinds.set(s.kind, (kinds.get(s.kind) ?? 0) + 1)
118+
}
119+
const kindText = Array.from(kinds.entries())
120+
.sort((a, b) => b[1] - a[1])
121+
.slice(0, 6)
122+
.map(([k, v]) => `${k}:${v}`)
123+
.join(", ")
124+
125+
const exportsList = file.exports.slice(0, 12).join(", ")
126+
const internalDeps = file.internalImports.length
127+
const externalDeps = file.externalImports.length
128+
129+
const parts = [
130+
`Exports: ${exportsList || "none"}`,
131+
`Deps: internal ${internalDeps}, external ${externalDeps}`,
132+
kindText ? `Symbols: ${kindText}` : "",
133+
].filter(Boolean)
134+
135+
return parts.join(". ")
136+
}
137+
64138
/**
65139
* Batch generate summaries cho nhiều files.
66140
* Gọi LLM song song với concurrency limit.
67141
*/
68142
async generateFileSummaries(
69143
files: ParsedFile[],
70-
concurrency = 5
144+
concurrency = 5,
145+
options: {
146+
cache?: SummaryCache | undefined
147+
getHash?: ((file: ParsedFile) => string) | undefined
148+
} = {}
71149
): Promise<Map<string, FileSummaryResult>> {
72150
const results = new Map<string, FileSummaryResult>()
151+
const cache = options.cache
152+
const getHash = options.getHash
73153

74154
// Process theo batch để tránh rate limit
75155
for (let i = 0; i < files.length; i += concurrency) {
76156
const batch = files.slice(i, i + concurrency)
77157
const batchResults = await Promise.all(
78158
batch.map(async (file) => {
79159
try {
80-
return await this.generateFileSummary(file)
160+
const gitHash = getHash ? getHash(file) : ""
161+
const cached = cache?.[file.relativePath]
162+
if (cached && cached.gitHash === gitHash && cached.shortSummary && cached.detailedSummary) {
163+
return {
164+
relativePath: file.relativePath,
165+
shortSummary: cached.shortSummary,
166+
detailedSummary: cached.detailedSummary,
167+
}
168+
}
169+
170+
if (this.mode === "heuristic") {
171+
const result = {
172+
relativePath: file.relativePath,
173+
shortSummary: this.heuristicShortSummary(file),
174+
detailedSummary: this.heuristicDetailedSummary(file),
175+
}
176+
if (cache && gitHash) {
177+
cache[file.relativePath] = {
178+
gitHash,
179+
shortSummary: result.shortSummary,
180+
detailedSummary: result.detailedSummary,
181+
updatedAt: Date.now(),
182+
}
183+
}
184+
return result
185+
}
186+
187+
const llmResult = await this.generateFileSummaryLLM(file)
188+
if (cache && gitHash) {
189+
cache[file.relativePath] = {
190+
gitHash,
191+
shortSummary: llmResult.shortSummary,
192+
detailedSummary: llmResult.detailedSummary,
193+
updatedAt: Date.now(),
194+
}
195+
}
196+
return llmResult
81197
} catch {
82-
// Fallback nếu LLM call fail
83-
return {
198+
const fallback = {
84199
relativePath: file.relativePath,
85-
shortSummary: `${file.relativePath} — TypeScript module`,
86-
detailedSummary: `Exports: ${file.exports.join(", ")}`,
200+
shortSummary: this.heuristicShortSummary(file),
201+
detailedSummary: this.heuristicDetailedSummary(file),
87202
}
203+
const gitHash = getHash ? getHash(file) : ""
204+
if (cache && gitHash) {
205+
cache[file.relativePath] = {
206+
gitHash,
207+
shortSummary: fallback.shortSummary,
208+
detailedSummary: fallback.detailedSummary,
209+
updatedAt: Date.now(),
210+
}
211+
}
212+
return fallback
88213
}
89214
})
90215
)
@@ -107,11 +232,22 @@ Respond with ONLY a JSON object in this exact format, no markdown:
107232
return { dirPath, shortSummary: `Module at ${dirPath}` }
108233
}
109234

235+
if (this.mode === "heuristic") {
236+
const sample = fileSummaries
237+
.map((f) => f.shortSummary)
238+
.filter(Boolean)
239+
.slice(0, 6)
240+
.join("; ")
241+
242+
const suffix = sample ? ` — ${sample}` : ""
243+
return { dirPath, shortSummary: `${dirPath}${fileSummaries.length} files${suffix}` }
244+
}
245+
110246
const fileList = fileSummaries
111247
.map((f) => `- ${f.relativePath}: ${f.shortSummary}`)
112248
.join("\n")
113249

114-
const prompt = `Summarize this TypeScript module (directory) in 1-2 sentences based on its files.
250+
const prompt = `Summarize this code module (directory) in 1-2 sentences based on its files.
115251
116252
Module: ${dirPath}
117253
Files:

0 commit comments

Comments
 (0)