@llmtune/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +181 -0
- package/dist/agent/conversation.d.ts.map +1 -0
- package/dist/agent/loop.d.ts.map +1 -0
- package/dist/agent/planner.d.ts.map +1 -0
- package/dist/auth/client.d.ts.map +1 -0
- package/dist/auth/config.d.ts.map +1 -0
- package/dist/commands/chat.d.ts.map +1 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/marketplace.d.ts.map +1 -0
- package/dist/commands/models.d.ts.map +1 -0
- package/dist/compact/history-store.d.ts.map +1 -0
- package/dist/compact/microcompact.d.ts.map +1 -0
- package/dist/compact/service.d.ts.map +1 -0
- package/dist/context/analyzer.d.ts.map +1 -0
- package/dist/context/builder.d.ts.map +1 -0
- package/dist/context/cache.d.ts.map +1 -0
- package/dist/context/git-context.d.ts.map +1 -0
- package/dist/context/llmtune-md.d.ts.map +1 -0
- package/dist/context/workspace.d.ts.map +1 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +125 -0
- package/dist/marketplace/client.d.ts.map +1 -0
- package/dist/memory/files.d.ts.map +1 -0
- package/dist/memory/service.d.ts.map +1 -0
- package/dist/repl/repl.d.ts.map +1 -0
- package/dist/skills/args.d.ts.map +1 -0
- package/dist/skills/frontmatter.d.ts.map +1 -0
- package/dist/skills/loader.d.ts.map +1 -0
- package/dist/skills/registry.d.ts.map +1 -0
- package/dist/skills/signing/signer.d.ts.map +1 -0
- package/dist/skills/trust.d.ts.map +1 -0
- package/dist/telemetry/logger.d.ts.map +1 -0
- package/dist/tools/permissions.d.ts.map +1 -0
- package/dist/tools/protocol.d.ts.map +1 -0
- package/dist/tools/registry.d.ts.map +1 -0
- package/dist/tools/sandbox/docker.d.ts.map +1 -0
- package/dist/tools/sandbox/index.d.ts.map +1 -0
- package/dist/tools/tools/ask-user.d.ts.map +1 -0
- package/dist/tools/tools/bash.d.ts.map +1 -0
- package/dist/tools/tools/edit.d.ts.map +1 -0
- package/dist/tools/tools/glob.d.ts.map +1 -0
- package/dist/tools/tools/grep.d.ts.map +1 -0
- package/dist/tools/tools/read.d.ts.map +1 -0
- package/dist/tools/tools/web-fetch.d.ts.map +1 -0
- package/dist/tools/tools/write.d.ts.map +1 -0
- package/dist/tools/validation.d.ts.map +1 -0
- package/dist/utils/markdown.d.ts.map +1 -0
- package/dist/utils/streaming.d.ts.map +1 -0
- package/dist/utils/tokens.d.ts.map +1 -0
- package/docs/SKILL_AUTHORING.md +175 -0
- package/package.json +38 -0
- package/src/agent/conversation.ts +140 -0
- package/src/agent/loop.ts +215 -0
- package/src/agent/planner.ts +55 -0
- package/src/auth/client.ts +19 -0
- package/src/auth/config.ts +89 -0
- package/src/commands/chat.ts +28 -0
- package/src/commands/config.ts +36 -0
- package/src/commands/login.ts +63 -0
- package/src/commands/marketplace.ts +190 -0
- package/src/commands/models.ts +74 -0
- package/src/compact/history-store.ts +101 -0
- package/src/compact/microcompact.ts +49 -0
- package/src/compact/service.ts +154 -0
- package/src/context/analyzer.ts +127 -0
- package/src/context/builder.ts +123 -0
- package/src/context/cache.ts +11 -0
- package/src/context/git-context.ts +58 -0
- package/src/context/llmtune-md.ts +48 -0
- package/src/context/workspace.ts +139 -0
- package/src/index.ts +100 -0
- package/src/marketplace/client.ts +118 -0
- package/src/memory/files.ts +81 -0
- package/src/memory/service.ts +124 -0
- package/src/repl/repl.ts +400 -0
- package/src/skills/args.ts +35 -0
- package/src/skills/builtin/explain-code/SKILL.md +30 -0
- package/src/skills/frontmatter.ts +47 -0
- package/src/skills/loader.ts +25 -0
- package/src/skills/registry.ts +155 -0
- package/src/skills/signing/signer.ts +101 -0
- package/src/skills/trust.ts +50 -0
- package/src/telemetry/logger.ts +108 -0
- package/src/tools/permissions.ts +83 -0
- package/src/tools/protocol.ts +24 -0
- package/src/tools/registry.ts +93 -0
- package/src/tools/sandbox/docker.ts +225 -0
- package/src/tools/sandbox/index.ts +91 -0
- package/src/tools/tools/ask-user.ts +60 -0
- package/src/tools/tools/bash.ts +97 -0
- package/src/tools/tools/edit.ts +111 -0
- package/src/tools/tools/glob.ts +68 -0
- package/src/tools/tools/grep.ts +121 -0
- package/src/tools/tools/read.ts +57 -0
- package/src/tools/tools/web-fetch.ts +158 -0
- package/src/tools/tools/write.ts +52 -0
- package/src/tools/validation.ts +164 -0
- package/src/utils/markdown.ts +96 -0
- package/src/utils/streaming.ts +63 -0
- package/src/utils/tokens.ts +41 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import OpenAI from "openai"
|
|
2
|
+
import { Conversation, type Message } from "../agent/conversation"
|
|
3
|
+
import { estimateTokens } from "../utils/tokens"
|
|
4
|
+
import * as fs from "fs"
|
|
5
|
+
import * as path from "path"
|
|
6
|
+
import * as os from "os"
|
|
7
|
+
|
|
8
|
+
export interface CompactResult {
|
|
9
|
+
tokensSaved: number
|
|
10
|
+
preCompactTokens: number
|
|
11
|
+
postCompactTokens: number
|
|
12
|
+
preCompactMessages: number
|
|
13
|
+
postCompactMessages: number
|
|
14
|
+
summary: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const COMPACT_SYSTEM_PROMPT = `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.
|
|
18
|
+
|
|
19
|
+
Your task is to create a detailed summary of the conversation so far.
|
|
20
|
+
|
|
21
|
+
Include:
|
|
22
|
+
1. Primary Request and Intent: What the user asked for
|
|
23
|
+
2. Key Technical Concepts: Technologies, frameworks, patterns discussed
|
|
24
|
+
3. Files and Code Sections: Files examined, modified, or created (with key snippets)
|
|
25
|
+
4. Errors and Fixes: Problems encountered and how they were resolved
|
|
26
|
+
5. Problem Solving: What was accomplished
|
|
27
|
+
6. All User Messages: Summarize all non-tool-result user messages
|
|
28
|
+
7. Pending Tasks: Tasks explicitly requested but not yet done
|
|
29
|
+
8. Current Work: What was being worked on immediately before this summary
|
|
30
|
+
|
|
31
|
+
Respond ONLY with plain text. No XML tags. No tool calls.`
|
|
32
|
+
|
|
33
|
+
export async function compactConversation(
|
|
34
|
+
client: OpenAI,
|
|
35
|
+
model: string,
|
|
36
|
+
conversation: Conversation,
|
|
37
|
+
sessionsDir?: string,
|
|
38
|
+
): Promise<CompactResult> {
|
|
39
|
+
const messages = conversation.messages
|
|
40
|
+
const preCompactTokens = estimateTokens(
|
|
41
|
+
messages.map((m) => (typeof m.content === "string" ? m.content : JSON.stringify(m.content))).join(" "),
|
|
42
|
+
)
|
|
43
|
+
const preCompactCount = messages.length
|
|
44
|
+
|
|
45
|
+
// Save raw history before compacting
|
|
46
|
+
saveRawHistory(conversation, sessionsDir)
|
|
47
|
+
|
|
48
|
+
// Build context for summary: last N messages
|
|
49
|
+
const contextMessages = messages.slice(-20)
|
|
50
|
+
const summaryRequestMessages = contextMessages
|
|
51
|
+
.filter((m) => m.role !== "system")
|
|
52
|
+
.map(
|
|
53
|
+
(m): OpenAI.ChatCompletionMessageParam => ({
|
|
54
|
+
role: m.role as "user" | "assistant",
|
|
55
|
+
content: typeof m.content === "string" ? m.content : JSON.stringify(m.content),
|
|
56
|
+
}),
|
|
57
|
+
)
|
|
58
|
+
summaryRequestMessages.push({ role: "user", content: COMPACT_SYSTEM_PROMPT })
|
|
59
|
+
|
|
60
|
+
let summary = ""
|
|
61
|
+
try {
|
|
62
|
+
const response = await client.chat.completions.create({
|
|
63
|
+
model,
|
|
64
|
+
messages: summaryRequestMessages,
|
|
65
|
+
max_tokens: 4096,
|
|
66
|
+
temperature: 0,
|
|
67
|
+
})
|
|
68
|
+
summary = response.choices[0]?.message?.content?.trim() ?? ""
|
|
69
|
+
} catch {
|
|
70
|
+
summary = buildFallbackSummary(messages)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!summary) {
|
|
74
|
+
summary = buildFallbackSummary(messages)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Replace conversation with boundary marker + summary
|
|
78
|
+
const boundaryMsg: Message = {
|
|
79
|
+
role: "system",
|
|
80
|
+
content: `[COMPACT BOUNDARY] Compacted at ${new Date().toISOString()}. ${preCompactCount} messages summarized. Raw history preserved.`,
|
|
81
|
+
}
|
|
82
|
+
const summaryMsg: Message = {
|
|
83
|
+
role: "system",
|
|
84
|
+
content: `## Conversation Summary\n\n${summary}`,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Keep system messages + boundary + summary
|
|
88
|
+
const systemMessages = messages.filter((m) => m.role === "system" && m === messages[0])
|
|
89
|
+
conversation.messages.length = 0
|
|
90
|
+
conversation.messages.push(...systemMessages, boundaryMsg, summaryMsg)
|
|
91
|
+
|
|
92
|
+
const postCompactTokens = estimateTokens(
|
|
93
|
+
conversation.messages.map((m) => (typeof m.content === "string" ? m.content : JSON.stringify(m.content))).join(" "),
|
|
94
|
+
)
|
|
95
|
+
const postCompactCount = conversation.messages.length
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
tokensSaved: Math.max(0, preCompactTokens - postCompactTokens),
|
|
99
|
+
preCompactTokens,
|
|
100
|
+
postCompactTokens,
|
|
101
|
+
preCompactMessages: preCompactCount,
|
|
102
|
+
postCompactMessages: postCompactCount,
|
|
103
|
+
summary,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function uncompactConversation(conversation: Conversation, sessionsDir?: string): boolean {
|
|
108
|
+
const dir = sessionsDir ?? path.join(os.homedir(), ".llmtune", "sessions")
|
|
109
|
+
const rawPath = path.join(dir, `${conversation.id}.raw.json`)
|
|
110
|
+
|
|
111
|
+
if (!fs.existsSync(rawPath)) {
|
|
112
|
+
return false
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const raw = JSON.parse(fs.readFileSync(rawPath, "utf-8")) as { messages: Message[] }
|
|
117
|
+
conversation.messages.length = 0
|
|
118
|
+
conversation.messages.push(...raw.messages)
|
|
119
|
+
return true
|
|
120
|
+
} catch {
|
|
121
|
+
return false
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function saveRawHistory(conversation: Conversation, sessionsDir?: string): void {
|
|
126
|
+
const dir = sessionsDir ?? path.join(os.homedir(), ".llmtune", "sessions")
|
|
127
|
+
if (!fs.existsSync(dir)) {
|
|
128
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
129
|
+
}
|
|
130
|
+
const rawPath = path.join(dir, `${conversation.id}.raw.json`)
|
|
131
|
+
if (!fs.existsSync(rawPath)) {
|
|
132
|
+
fs.writeFileSync(rawPath, JSON.stringify({ messages: conversation.messages }, null, 2), "utf-8")
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function buildFallbackSummary(messages: Message[]): string {
|
|
137
|
+
const userMsgs = messages
|
|
138
|
+
.filter((m) => m.role === "user")
|
|
139
|
+
.map((m) => (typeof m.content === "string" ? m.content.slice(0, 200) : ""))
|
|
140
|
+
.filter(Boolean)
|
|
141
|
+
const toolNames = messages
|
|
142
|
+
.filter((m) => m.role === "assistant" && m.toolCalls)
|
|
143
|
+
.flatMap((m) => m.toolCalls?.map((tc) => tc.function.name) ?? [])
|
|
144
|
+
|
|
145
|
+
const parts = [`Conversation had ${messages.length} messages.`]
|
|
146
|
+
if (toolNames.length > 0) {
|
|
147
|
+
const unique = [...new Set(toolNames)]
|
|
148
|
+
parts.push(`Tools used: ${unique.join(", ")}`)
|
|
149
|
+
}
|
|
150
|
+
if (userMsgs.length > 0) {
|
|
151
|
+
parts.push(`Last user message: ${userMsgs[userMsgs.length - 1].slice(0, 150)}`)
|
|
152
|
+
}
|
|
153
|
+
return parts.join("\n")
|
|
154
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { ToolSpec } from "../tools/protocol"
|
|
2
|
+
import { estimateTokens } from "../utils/tokens"
|
|
3
|
+
|
|
4
|
+
export interface ContextCategory {
|
|
5
|
+
name: string
|
|
6
|
+
tokens: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ContextAnalysis {
|
|
10
|
+
categories: ContextCategory[]
|
|
11
|
+
totalTokens: number
|
|
12
|
+
maxTokens: number
|
|
13
|
+
percentage: number
|
|
14
|
+
model: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const MODEL_CONTEXT_WINDOWS: Record<string, number> = {
|
|
18
|
+
"gpt-4o": 128_000,
|
|
19
|
+
"gpt-4o-mini": 128_000,
|
|
20
|
+
"gpt-4-turbo": 128_000,
|
|
21
|
+
"glm-5.1": 128_000,
|
|
22
|
+
"glm-5-turbo": 128_000,
|
|
23
|
+
"glm-4.7-flash": 128_000,
|
|
24
|
+
"claude-sonnet": 200_000,
|
|
25
|
+
"claude-opus": 200_000,
|
|
26
|
+
"deepseek-r1": 128_000,
|
|
27
|
+
"qwen3": 128_000,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DEFAULT_CONTEXT_WINDOW = 128_000
|
|
31
|
+
|
|
32
|
+
export function getContextWindowForModel(model: string): number {
|
|
33
|
+
const lower = model.toLowerCase()
|
|
34
|
+
for (const [name, window] of Object.entries(MODEL_CONTEXT_WINDOWS)) {
|
|
35
|
+
if (lower.includes(name)) return window
|
|
36
|
+
}
|
|
37
|
+
const kMatch = lower.match(/(\d+)k/)
|
|
38
|
+
if (kMatch) return parseInt(kMatch[1], 10) * 1_000
|
|
39
|
+
if (lower.includes("1m") || lower.includes("million")) return 1_000_000
|
|
40
|
+
return DEFAULT_CONTEXT_WINDOW
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function analyzeContextUsage(options: {
|
|
44
|
+
systemPrompt: string
|
|
45
|
+
toolSpecs: ToolSpec[]
|
|
46
|
+
messages: Array<{ role: string; content: unknown }>
|
|
47
|
+
skillsContent?: string
|
|
48
|
+
memoryContent?: string
|
|
49
|
+
model: string
|
|
50
|
+
}): ContextAnalysis {
|
|
51
|
+
const categories: ContextCategory[] = []
|
|
52
|
+
|
|
53
|
+
const systemTokens = estimateTokens(options.systemPrompt)
|
|
54
|
+
if (systemTokens > 0) {
|
|
55
|
+
categories.push({ name: "System prompt", tokens: systemTokens })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let toolTokens = 0
|
|
59
|
+
for (const spec of options.toolSpecs) {
|
|
60
|
+
toolTokens += estimateTokens(spec.description)
|
|
61
|
+
toolTokens += estimateTokens(JSON.stringify(spec.inputSchema))
|
|
62
|
+
}
|
|
63
|
+
if (toolTokens > 0) {
|
|
64
|
+
categories.push({ name: "Tool definitions", tokens: toolTokens })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (options.skillsContent) {
|
|
68
|
+
const skillsTokens = estimateTokens(options.skillsContent)
|
|
69
|
+
if (skillsTokens > 0) {
|
|
70
|
+
categories.push({ name: "Skills", tokens: skillsTokens })
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (options.memoryContent) {
|
|
75
|
+
const memTokens = estimateTokens(options.memoryContent)
|
|
76
|
+
if (memTokens > 0) {
|
|
77
|
+
categories.push({ name: "Memory", tokens: memTokens })
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let messageTokens = 0
|
|
82
|
+
for (const msg of options.messages) {
|
|
83
|
+
const text = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content ?? "")
|
|
84
|
+
messageTokens += estimateTokens(text)
|
|
85
|
+
}
|
|
86
|
+
if (messageTokens > 0) {
|
|
87
|
+
categories.push({ name: "Messages", tokens: messageTokens })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const totalTokens = categories.reduce((sum, c) => sum + c.tokens, 0)
|
|
91
|
+
const maxTokens = getContextWindowForModel(options.model)
|
|
92
|
+
const percentage = maxTokens > 0 ? Math.round((totalTokens / maxTokens) * 1000) / 10 : 0
|
|
93
|
+
|
|
94
|
+
const freeTokens = Math.max(0, maxTokens - totalTokens)
|
|
95
|
+
categories.push({ name: "Free space", tokens: freeTokens })
|
|
96
|
+
|
|
97
|
+
return { categories, totalTokens, maxTokens, percentage, model: options.model }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function formatContextAnalysis(analysis: ContextAnalysis): string {
|
|
101
|
+
const lines: string[] = [
|
|
102
|
+
"## Context Usage",
|
|
103
|
+
"",
|
|
104
|
+
`**Model:** ${analysis.model}`,
|
|
105
|
+
`**Tokens:** ${analysis.totalTokens.toLocaleString()} / ${analysis.maxTokens.toLocaleString()} (${analysis.percentage}%)`,
|
|
106
|
+
"",
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
const visible = analysis.categories.filter((c) => c.tokens > 0 && c.name !== "Free space")
|
|
110
|
+
|
|
111
|
+
if (visible.length > 0) {
|
|
112
|
+
lines.push("| Category | Tokens | Percentage |")
|
|
113
|
+
lines.push("|----------|--------|------------|")
|
|
114
|
+
for (const cat of visible) {
|
|
115
|
+
const pct = analysis.maxTokens > 0 ? ((cat.tokens / analysis.maxTokens) * 100).toFixed(1) : "0.0"
|
|
116
|
+
lines.push(`| ${cat.name} | ${cat.tokens.toLocaleString()} | ${pct}% |`)
|
|
117
|
+
}
|
|
118
|
+
const free = analysis.categories.find((c) => c.name === "Free space")
|
|
119
|
+
if (free && free.tokens > 0) {
|
|
120
|
+
const pct = analysis.maxTokens > 0 ? ((free.tokens / analysis.maxTokens) * 100).toFixed(1) : "0.0"
|
|
121
|
+
lines.push(`| Free space | ${free.tokens.toLocaleString()} | ${pct}% |`)
|
|
122
|
+
}
|
|
123
|
+
lines.push("")
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return lines.join("\n")
|
|
127
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import * as path from "path"
|
|
2
|
+
import * as crypto from "crypto"
|
|
3
|
+
import * as fs from "fs"
|
|
4
|
+
import { collectGitContext, type GitContext } from "./git-context"
|
|
5
|
+
import { buildWorkspaceSnapshot, renderWorkspaceSection, type WorkspaceSnapshot } from "./workspace"
|
|
6
|
+
import { loadProjectInstructions } from "./llmtune-md"
|
|
7
|
+
|
|
8
|
+
export interface ContextResult {
|
|
9
|
+
prompt: string
|
|
10
|
+
cacheKey: string
|
|
11
|
+
cacheHit: boolean
|
|
12
|
+
sections: string[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const CACHE_DIR = () => {
|
|
16
|
+
const base = process.env.LLMTUNE_CACHE_DIR || path.join(process.env.HOME || process.env.USERPROFILE || "~", ".llmtune", "cache")
|
|
17
|
+
return base
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getCachePath(cacheKey: string): string {
|
|
21
|
+
return path.join(CACHE_DIR(), `${cacheKey}.json`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function computeCacheKey(workspaceRoot: string, cwd: string): string {
|
|
25
|
+
const git = collectGitContext(workspaceRoot)
|
|
26
|
+
const base = `${workspaceRoot}:${cwd}`
|
|
27
|
+
if (git.available && git.recentCommit) {
|
|
28
|
+
return crypto.createHash("sha256").update(`${base}:${git.recentCommit}`).digest("hex").slice(0, 16)
|
|
29
|
+
}
|
|
30
|
+
return crypto.createHash("sha256").update(base).digest("hex").slice(0, 16)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function readCache(cacheKey: string): ContextResult | null {
|
|
34
|
+
try {
|
|
35
|
+
const cachePath = getCachePath(cacheKey)
|
|
36
|
+
if (fs.existsSync(cachePath)) {
|
|
37
|
+
const stat = fs.statSync(cachePath)
|
|
38
|
+
const ageMs = Date.now() - stat.mtimeMs
|
|
39
|
+
if (ageMs > 30 * 60 * 1000) return null
|
|
40
|
+
const data = JSON.parse(fs.readFileSync(cachePath, "utf-8"))
|
|
41
|
+
return { ...data, cacheHit: true }
|
|
42
|
+
}
|
|
43
|
+
} catch {}
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function writeCache(cacheKey: string, result: ContextResult): void {
|
|
48
|
+
try {
|
|
49
|
+
const cacheDir = CACHE_DIR()
|
|
50
|
+
if (!fs.existsSync(cacheDir)) {
|
|
51
|
+
fs.mkdirSync(cacheDir, { recursive: true })
|
|
52
|
+
}
|
|
53
|
+
const cachePath = getCachePath(cacheKey)
|
|
54
|
+
fs.writeFileSync(cachePath, JSON.stringify({ ...result, cacheHit: false }, null, 2))
|
|
55
|
+
} catch {}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function buildContextPrompt(
|
|
59
|
+
workspaceRoot: string,
|
|
60
|
+
cwd: string,
|
|
61
|
+
options?: { useCache?: boolean }
|
|
62
|
+
): Promise<ContextResult> {
|
|
63
|
+
const cacheKey = computeCacheKey(workspaceRoot, cwd)
|
|
64
|
+
|
|
65
|
+
if (options?.useCache !== false) {
|
|
66
|
+
const cached = readCache(cacheKey)
|
|
67
|
+
if (cached) return cached
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const sections: string[] = []
|
|
71
|
+
|
|
72
|
+
// Workspace section (async)
|
|
73
|
+
const workspace = await buildWorkspaceSnapshot(workspaceRoot, cwd)
|
|
74
|
+
const workspaceSection = renderWorkspaceSection(workspace)
|
|
75
|
+
if (workspaceSection) sections.push(workspaceSection)
|
|
76
|
+
|
|
77
|
+
// Git section
|
|
78
|
+
const git = collectGitContext(workspaceRoot)
|
|
79
|
+
const gitSection = renderGitSection(git)
|
|
80
|
+
if (gitSection) sections.push(gitSection)
|
|
81
|
+
|
|
82
|
+
// LLMTUNE.md / CLAUDE.md section
|
|
83
|
+
const mdFiles = loadProjectInstructions(workspaceRoot, cwd)
|
|
84
|
+
const mdSection = renderMdSection(mdFiles, workspaceRoot)
|
|
85
|
+
if (mdSection) sections.push(mdSection)
|
|
86
|
+
|
|
87
|
+
const prompt = sections.join("\n\n")
|
|
88
|
+
|
|
89
|
+
const result: ContextResult = {
|
|
90
|
+
prompt,
|
|
91
|
+
cacheKey,
|
|
92
|
+
cacheHit: false,
|
|
93
|
+
sections,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
writeCache(cacheKey, result)
|
|
97
|
+
return result
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function renderGitSection(git: GitContext): string {
|
|
101
|
+
if (!git.available) return ""
|
|
102
|
+
const lines = ["## Git Context"]
|
|
103
|
+
if (git.branch) lines.push(`- Current branch: ${git.branch}`)
|
|
104
|
+
if (git.recentCommit) lines.push(`- Latest commit: ${git.recentCommit}`)
|
|
105
|
+
if (git.status) {
|
|
106
|
+
lines.push("- Git status snapshot:", "```text", git.status.slice(0, 2000), "```")
|
|
107
|
+
}
|
|
108
|
+
return lines.join("\n")
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function renderMdSection(
|
|
112
|
+
files: Array<{ path: string; content: string }>,
|
|
113
|
+
workspaceRoot: string
|
|
114
|
+
): string {
|
|
115
|
+
if (files.length === 0) return ""
|
|
116
|
+
const lines = ["## Project Instructions"]
|
|
117
|
+
for (const f of files) {
|
|
118
|
+
const relative = path.relative(workspaceRoot, f.path)
|
|
119
|
+
const label = relative ? `./${relative}` : f.path
|
|
120
|
+
lines.push(`### ${label}`, "```md", f.content.slice(0, 4000), "```")
|
|
121
|
+
}
|
|
122
|
+
return lines.join("\n")
|
|
123
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { buildContextPrompt, type ContextResult } from "./builder"
|
|
2
|
+
|
|
3
|
+
export type { ContextResult }
|
|
4
|
+
|
|
5
|
+
export async function getContext(
|
|
6
|
+
workspaceRoot: string,
|
|
7
|
+
cwd: string,
|
|
8
|
+
options?: { useCache?: boolean }
|
|
9
|
+
): Promise<ContextResult> {
|
|
10
|
+
return buildContextPrompt(workspaceRoot, cwd, options)
|
|
11
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { execSync } from "child_process";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
export interface GitContext {
|
|
5
|
+
available: boolean;
|
|
6
|
+
repoRoot: string | null;
|
|
7
|
+
branch: string | null;
|
|
8
|
+
recentCommit: string | null;
|
|
9
|
+
status: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function collectGitContext(workspaceRoot: string): GitContext {
|
|
13
|
+
try {
|
|
14
|
+
const gitDir = execSync("git rev-parse --show-toplevel 2>/dev/null", {
|
|
15
|
+
cwd: workspaceRoot,
|
|
16
|
+
encoding: "utf-8",
|
|
17
|
+
timeout: 5000,
|
|
18
|
+
}).trim();
|
|
19
|
+
|
|
20
|
+
if (!gitDir) {
|
|
21
|
+
return { available: false, repoRoot: null, branch: null, recentCommit: null, status: null };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const branch = execSync("git rev-parse --abbrev-ref HEAD 2>/dev/null", {
|
|
25
|
+
cwd: gitDir,
|
|
26
|
+
encoding: "utf-8",
|
|
27
|
+
timeout: 5000,
|
|
28
|
+
}).trim();
|
|
29
|
+
|
|
30
|
+
const recentCommit = execSync("git log -1 --oneline 2>/dev/null", {
|
|
31
|
+
cwd: gitDir,
|
|
32
|
+
encoding: "utf-8",
|
|
33
|
+
timeout: 5000,
|
|
34
|
+
}).trim();
|
|
35
|
+
|
|
36
|
+
let status: string | null = null;
|
|
37
|
+
try {
|
|
38
|
+
const raw = execSync("git status --short 2>/dev/null", {
|
|
39
|
+
cwd: gitDir,
|
|
40
|
+
encoding: "utf-8",
|
|
41
|
+
timeout: 5000,
|
|
42
|
+
}).trim();
|
|
43
|
+
if (raw) {
|
|
44
|
+
const lines = raw.split("\n").slice(0, 30);
|
|
45
|
+
status = lines.join("\n");
|
|
46
|
+
if (raw.split("\n").length > 30) {
|
|
47
|
+
status += `\n... ${raw.split("\n").length - 30} more files`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// status unavailable, non-critical
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { available: true, repoRoot: gitDir, branch, recentCommit, status };
|
|
55
|
+
} catch {
|
|
56
|
+
return { available: false, repoRoot: null, branch: null, recentCommit: null, status: null };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
|
|
4
|
+
export interface MarkdownFile {
|
|
5
|
+
path: string;
|
|
6
|
+
content: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const FILENAMES = ["LLMTUNE.md", "CLAUDE.md", ".llmtune", ".claude"];
|
|
10
|
+
|
|
11
|
+
export function loadProjectInstructions(
|
|
12
|
+
workspaceRoot: string,
|
|
13
|
+
cwd: string
|
|
14
|
+
): MarkdownFile[] {
|
|
15
|
+
const files: MarkdownFile[] = [];
|
|
16
|
+
const seen = new Set<string>();
|
|
17
|
+
|
|
18
|
+
const dirsToCheck = [cwd];
|
|
19
|
+
let current = cwd;
|
|
20
|
+
while (current !== workspaceRoot && current !== path.dirname(current)) {
|
|
21
|
+
current = path.dirname(current);
|
|
22
|
+
dirsToCheck.push(current);
|
|
23
|
+
}
|
|
24
|
+
dirsToCheck.push(workspaceRoot);
|
|
25
|
+
|
|
26
|
+
for (const dir of dirsToCheck) {
|
|
27
|
+
for (const filename of FILENAMES) {
|
|
28
|
+
const filePath = path.join(dir, filename);
|
|
29
|
+
const resolved = path.resolve(filePath);
|
|
30
|
+
if (seen.has(resolved)) continue;
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const stat = fs.statSync(filePath);
|
|
34
|
+
if (stat.isFile()) {
|
|
35
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
36
|
+
if (content.trim()) {
|
|
37
|
+
seen.add(resolved);
|
|
38
|
+
files.push({ path: resolved, content: content.trim() });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// file doesn't exist, skip
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return files;
|
|
48
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { readdir, stat } from "fs/promises";
|
|
2
|
+
import { join, relative, extname } from "path";
|
|
3
|
+
|
|
4
|
+
export interface WorkspaceSnapshot {
|
|
5
|
+
workspace_root: string;
|
|
6
|
+
current_directory: string;
|
|
7
|
+
top_level_entries: string[];
|
|
8
|
+
key_files: string[];
|
|
9
|
+
total_files: number;
|
|
10
|
+
file_type_counts: Record<string, number>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const KEY_FILE_NAMES = new Set([
|
|
14
|
+
"package.json",
|
|
15
|
+
"tsconfig.json",
|
|
16
|
+
"Cargo.toml",
|
|
17
|
+
"go.mod",
|
|
18
|
+
"pyproject.toml",
|
|
19
|
+
"requirements.txt",
|
|
20
|
+
"Makefile",
|
|
21
|
+
"Dockerfile",
|
|
22
|
+
"docker-compose.yml",
|
|
23
|
+
"README.md",
|
|
24
|
+
"CLAUDE.md",
|
|
25
|
+
"LLMTUNE.md",
|
|
26
|
+
".env.example",
|
|
27
|
+
".gitignore",
|
|
28
|
+
"prisma",
|
|
29
|
+
"schema.prisma",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const IGNORED_DIRS = new Set([
|
|
33
|
+
"node_modules",
|
|
34
|
+
".git",
|
|
35
|
+
"__pycache__",
|
|
36
|
+
".next",
|
|
37
|
+
"dist",
|
|
38
|
+
"build",
|
|
39
|
+
".venv",
|
|
40
|
+
"venv",
|
|
41
|
+
".tox",
|
|
42
|
+
"target",
|
|
43
|
+
".cache",
|
|
44
|
+
".turbo",
|
|
45
|
+
"coverage",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
export async function buildWorkspaceSnapshot(
|
|
49
|
+
workspaceRoot: string,
|
|
50
|
+
cwd?: string,
|
|
51
|
+
): Promise<WorkspaceSnapshot> {
|
|
52
|
+
const root = workspaceRoot;
|
|
53
|
+
const current = cwd ?? workspaceRoot;
|
|
54
|
+
|
|
55
|
+
const topEntries = await safeReaddir(root);
|
|
56
|
+
const topLevelEntries = topEntries.map((e) => e.name);
|
|
57
|
+
|
|
58
|
+
const keyFiles: string[] = [];
|
|
59
|
+
const fileTypeCounts: Record<string, number> = {};
|
|
60
|
+
let totalFiles = 0;
|
|
61
|
+
|
|
62
|
+
await walkDir(root, "", async (name, relPath, isDir) => {
|
|
63
|
+
if (isDir) return;
|
|
64
|
+
totalFiles++;
|
|
65
|
+
const ext = extname(name).toLowerCase();
|
|
66
|
+
fileTypeCounts[ext] = (fileTypeCounts[ext] ?? 0) + 1;
|
|
67
|
+
if (KEY_FILE_NAMES.has(name)) {
|
|
68
|
+
keyFiles.push(relative(root, join(root, relPath, name)));
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
workspace_root: root,
|
|
74
|
+
current_directory: current,
|
|
75
|
+
top_level_entries: topLevelEntries.slice(0, 30),
|
|
76
|
+
key_files: keyFiles.slice(0, 20),
|
|
77
|
+
total_files: totalFiles,
|
|
78
|
+
file_type_counts: fileTypeCounts,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function renderWorkspaceSection(snapshot: WorkspaceSnapshot): string {
|
|
83
|
+
const today = new Date().toISOString().split("T")[0];
|
|
84
|
+
const lines = [
|
|
85
|
+
"## Runtime Context",
|
|
86
|
+
`- Today's date: ${today}`,
|
|
87
|
+
`- Workspace root: ${snapshot.workspace_root}`,
|
|
88
|
+
`- Current directory: ${snapshot.current_directory}`,
|
|
89
|
+
`- Total files: ${snapshot.total_files}`,
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
const topExts = Object.entries(snapshot.file_type_counts)
|
|
93
|
+
.sort((a, b) => b[1] - a[1])
|
|
94
|
+
.slice(0, 5)
|
|
95
|
+
.map(([ext, count]) => `${ext || "none"}: ${count}`);
|
|
96
|
+
if (topExts.length > 0) {
|
|
97
|
+
lines.push(`- Top file types: ${topExts.join(", ")}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (snapshot.key_files.length > 0) {
|
|
101
|
+
lines.push(`- Key files: ${snapshot.key_files.join(", ")}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (snapshot.top_level_entries.length > 0) {
|
|
105
|
+
lines.push(
|
|
106
|
+
`- Top-level entries: ${snapshot.top_level_entries.slice(0, 15).join(", ")}`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return lines.join("\n");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function walkDir(
|
|
114
|
+
root: string,
|
|
115
|
+
relDir: string,
|
|
116
|
+
visitor: (name: string, relPath: string, isDir: boolean) => Promise<void>,
|
|
117
|
+
depth = 0,
|
|
118
|
+
): Promise<void> {
|
|
119
|
+
if (depth > 3) return;
|
|
120
|
+
const entries = await safeReaddir(join(root, relDir));
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
123
|
+
const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
|
|
124
|
+
const isDir = entry.isDirectory();
|
|
125
|
+
await visitor(entry.name, relDir, isDir);
|
|
126
|
+
if (isDir && depth < 3) {
|
|
127
|
+
await walkDir(root, relPath, visitor, depth + 1);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function safeReaddir(dirPath: string) {
|
|
133
|
+
try {
|
|
134
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
135
|
+
return entries;
|
|
136
|
+
} catch {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
}
|