min-agent 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.
@@ -0,0 +1,119 @@
1
+ import { generateText, type ModelMessage, type LanguageModel } from "ai"
2
+
3
+ /**
4
+ * Context compaction system.
5
+ *
6
+ * When conversation history exceeds a token threshold, older messages are
7
+ * summarized into a compact form to free up context window space.
8
+ *
9
+ * Strategy:
10
+ * 1. Estimate token count of messages (rough: 1 token ≈ 4 chars for English, 2 chars for CJK)
11
+ * 2. When over threshold, take older messages and summarize them via the LLM
12
+ * 3. Replace old messages with a single system summary message
13
+ * 4. Keep recent N turns verbatim for continuity
14
+ */
15
+
16
+ const COMPACTION_PROMPT = `You are a conversation summarizer. Summarize the following conversation history into a concise but complete summary that preserves:
17
+ - Key decisions made
18
+ - Important context and facts discussed
19
+ - Current state of any tasks in progress
20
+ - User preferences mentioned
21
+ - File paths, code snippets, or technical details that are still relevant
22
+
23
+ Be concise but don't lose critical information. Output only the summary, no preamble.`
24
+
25
+ // Default: trigger compaction at ~80% of context window
26
+ const DEFAULT_MAX_TOKENS = 128000
27
+ const COMPACTION_RATIO = 0.75
28
+ const KEEP_RECENT_TURNS = 4 // Keep last N user+assistant pairs verbatim
29
+
30
+ export interface CompactionConfig {
31
+ maxTokens?: number
32
+ keepRecentTurns?: number
33
+ }
34
+
35
+ /** Rough token estimation */
36
+ export function estimateTokens(messages: ModelMessage[]): number {
37
+ let chars = 0
38
+ for (const msg of messages) {
39
+ if (typeof msg.content === "string") {
40
+ chars += msg.content.length
41
+ } else if (Array.isArray(msg.content)) {
42
+ for (const part of msg.content) {
43
+ if ("text" in part && typeof part.text === "string") {
44
+ chars += part.text.length
45
+ }
46
+ }
47
+ }
48
+ }
49
+ // Rough estimate: mix of English (~4 chars/token) and CJK (~2 chars/token)
50
+ return Math.ceil(chars / 3)
51
+ }
52
+
53
+ /** Check if compaction is needed */
54
+ export function needsCompaction(messages: ModelMessage[], config?: CompactionConfig): boolean {
55
+ const maxTokens = config?.maxTokens ?? DEFAULT_MAX_TOKENS
56
+ const threshold = maxTokens * COMPACTION_RATIO
57
+ return estimateTokens(messages) > threshold
58
+ }
59
+
60
+ /** Compact messages by summarizing older history */
61
+ export async function compactMessages(
62
+ messages: ModelMessage[],
63
+ model: LanguageModel,
64
+ config?: CompactionConfig,
65
+ ): Promise<{ messages: ModelMessage[]; compacted: boolean }> {
66
+ const keepTurns = config?.keepRecentTurns ?? KEEP_RECENT_TURNS
67
+
68
+ if (messages.length <= keepTurns * 2) {
69
+ // Not enough messages to compact
70
+ return { messages, compacted: false }
71
+ }
72
+
73
+ // Split: older messages to summarize, recent messages to keep
74
+ const splitIdx = messages.length - keepTurns * 2
75
+ const toSummarize = messages.slice(0, splitIdx)
76
+ const toKeep = messages.slice(splitIdx)
77
+
78
+ // Build conversation text for summarization
79
+ const conversationText = toSummarize
80
+ .map((msg) => {
81
+ const role = msg.role
82
+ const content = typeof msg.content === "string"
83
+ ? msg.content
84
+ : Array.isArray(msg.content)
85
+ ? msg.content
86
+ .filter((p): p is { type: "text"; text: string } => "text" in p)
87
+ .map((p) => p.text)
88
+ .join("\n")
89
+ : ""
90
+ return `[${role}]: ${content.slice(0, 2000)}`
91
+ })
92
+ .join("\n\n")
93
+
94
+ try {
95
+ const result = await generateText({
96
+ model,
97
+ messages: [
98
+ { role: "system", content: COMPACTION_PROMPT },
99
+ { role: "user", content: `Summarize this conversation:\n\n${conversationText}` },
100
+ ],
101
+ })
102
+
103
+ const summary = result.text
104
+
105
+ // Build compacted message list
106
+ const compactedMessages: ModelMessage[] = [
107
+ {
108
+ role: "system",
109
+ content: `[Context Summary - Previous conversation was compacted]\n\n${summary}`,
110
+ },
111
+ ...toKeep,
112
+ ]
113
+
114
+ return { messages: compactedMessages, compacted: true }
115
+ } catch {
116
+ // If summarization fails, just truncate older messages
117
+ return { messages: toKeep, compacted: true }
118
+ }
119
+ }
package/src/config.ts ADDED
@@ -0,0 +1,172 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"
2
+ import path from "path"
3
+ import os from "os"
4
+ import readline from "readline"
5
+
6
+ const CONFIG_DIR = path.join(os.homedir(), ".min-agent")
7
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json")
8
+ const RULES_FILE = path.join(CONFIG_DIR, "rules.md")
9
+
10
+ export interface ProviderConfig {
11
+ type?: "openai-compatible" | "openai" | "ollama"
12
+ baseURL: string
13
+ apiKey: string
14
+ defaultModel?: string
15
+ }
16
+
17
+ export interface AppConfig {
18
+ provider?: ProviderConfig
19
+ instructions?: string[]
20
+ }
21
+
22
+ export function getConfigDir(): string {
23
+ return CONFIG_DIR
24
+ }
25
+
26
+ export function getRulesFile(): string {
27
+ return RULES_FILE
28
+ }
29
+
30
+ export function loadConfig(): AppConfig {
31
+ if (!existsSync(CONFIG_FILE)) return {}
32
+ try {
33
+ return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"))
34
+ } catch {
35
+ return {}
36
+ }
37
+ }
38
+
39
+ export function saveConfig(config: AppConfig) {
40
+ mkdirSync(CONFIG_DIR, { recursive: true })
41
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8")
42
+ }
43
+
44
+ export function isConfigured(): boolean {
45
+ const config = loadConfig()
46
+ return !!(config.provider?.baseURL && config.provider?.apiKey)
47
+ }
48
+
49
+ async function fetchModelsFromURL(url: string, apiKey: string): Promise<string[]> {
50
+ const noCacheURL = new URL(url)
51
+ noCacheURL.searchParams.set("_t", Date.now().toString())
52
+
53
+ const response = await fetch(noCacheURL.toString(), {
54
+ headers: {
55
+ Authorization: `Bearer ${apiKey}`,
56
+ "Cache-Control": "no-cache, no-store, must-revalidate",
57
+ Pragma: "no-cache",
58
+ Expires: "0",
59
+ },
60
+ cache: "no-store",
61
+ signal: AbortSignal.timeout(10000),
62
+ })
63
+ if (!response.ok) return []
64
+ const data = (await response.json()) as any
65
+ const models = (data.data ?? data ?? []) as Array<{ id: string }>
66
+ return models.map((m) => m.id).sort()
67
+ }
68
+
69
+ function ask(rl: readline.Interface, question: string, defaultValue?: string): Promise<string> {
70
+ const suffix = defaultValue ? ` (${defaultValue})` : ""
71
+ return new Promise((resolve) => {
72
+ rl.question(`${question}${suffix}: `, (answer) => {
73
+ resolve(answer.trim() || defaultValue || "")
74
+ })
75
+ })
76
+ }
77
+
78
+ export async function fetchModels(baseURL: string, apiKey: string): Promise<string[]> {
79
+ try {
80
+ const trimmed = baseURL.replace(/\/$/, "")
81
+ const primary = await fetchModelsFromURL(`${trimmed}/models`, apiKey)
82
+ if (primary.length > 0) return primary
83
+
84
+ // Ollama users often provide host without /v1; auto-retry that variant.
85
+ if (!trimmed.endsWith("/v1")) {
86
+ return await fetchModelsFromURL(`${trimmed}/v1/models`, apiKey)
87
+ }
88
+
89
+ return []
90
+ } catch {
91
+ return []
92
+ }
93
+ }
94
+
95
+ export async function runSetup(): Promise<void> {
96
+ const config = loadConfig()
97
+ const existing = config.provider
98
+
99
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
100
+
101
+ console.log("\n🔧 min-agent 配置\n")
102
+
103
+ console.log("Provider 类型:")
104
+ console.log(" 1. openai-compatible (默认,兼容 OpenAI API 的任意服务)")
105
+ console.log(" 2. openai (OpenAI 官方)")
106
+ console.log(" 3. ollama (本地 Ollama)")
107
+ console.log()
108
+
109
+ const typeChoice = await ask(rl, "选择 Provider (1/2/3)", existing?.type === "ollama" ? "3" : existing?.type === "openai" ? "2" : "1")
110
+ const providerType = typeChoice === "3" ? "ollama" as const
111
+ : typeChoice === "2" ? "openai" as const
112
+ : "openai-compatible" as const
113
+
114
+ let baseURL: string
115
+ let apiKey: string
116
+
117
+ if (providerType === "ollama") {
118
+ baseURL = await ask(rl, "Ollama API URL", existing?.baseURL || "http://localhost:11434/v1")
119
+ if (!baseURL.replace(/\/$/, "").endsWith("/v1")) {
120
+ baseURL = `${baseURL.replace(/\/$/, "")}/v1`
121
+ }
122
+ apiKey = "ollama" // Ollama doesn't need a real key
123
+ } else if (providerType === "openai") {
124
+ baseURL = "https://api.openai.com/v1"
125
+ apiKey = await ask(rl, "OpenAI API Key", existing?.apiKey)
126
+ } else {
127
+ baseURL = await ask(rl, "API Base URL", existing?.baseURL || "https://api.openai.com/v1")
128
+ apiKey = await ask(rl, "API Key", existing?.apiKey)
129
+ }
130
+
131
+ if (!baseURL || (!apiKey && providerType !== "ollama")) {
132
+ console.error("URL 和 Key 不能为空")
133
+ rl.close()
134
+ process.exit(1)
135
+ }
136
+
137
+ console.log("\n正在获取模型列表...")
138
+ const models = await fetchModels(baseURL, apiKey)
139
+
140
+ let defaultModel = existing?.defaultModel ?? ""
141
+
142
+ if (models.length > 0) {
143
+ console.log(`\n可用模型 (${models.length}):`)
144
+ models.forEach((m, i) => {
145
+ const marker = m === defaultModel ? " ← 当前默认" : ""
146
+ console.log(` ${i + 1}. ${m}${marker}`)
147
+ })
148
+ console.log()
149
+
150
+ const choice = await ask(rl, "选择默认模型 (输入序号或模型名)", defaultModel)
151
+ const idx = parseInt(choice) - 1
152
+ if (idx >= 0 && idx < models.length) {
153
+ defaultModel = models[idx]
154
+ } else if (choice) {
155
+ defaultModel = choice
156
+ }
157
+ } else {
158
+ console.log(" ⚠ 无法获取模型列表,请手动输入模型名")
159
+ const hint = providerType === "ollama" ? "llama3" : providerType === "openai" ? "gpt-4o" : ""
160
+ defaultModel = await ask(rl, "默认模型", defaultModel || hint)
161
+ }
162
+
163
+ rl.close()
164
+
165
+ config.provider = { type: providerType, baseURL, apiKey, defaultModel }
166
+ saveConfig(config)
167
+
168
+ console.log(`\n✓ 配置已保存到 ${CONFIG_FILE}`)
169
+ console.log(` Type: ${providerType}`)
170
+ console.log(` URL: ${baseURL}`)
171
+ console.log(` Model: ${defaultModel}\n`)
172
+ }
package/src/confirm.ts ADDED
@@ -0,0 +1,42 @@
1
+ import readline from "readline"
2
+
3
+ let autoApprove = false
4
+
5
+ export function setAutoApprove(value: boolean) {
6
+ autoApprove = value
7
+ }
8
+
9
+ export function isAutoApprove(): boolean {
10
+ return autoApprove
11
+ }
12
+
13
+ /** Ask user for confirmation. Returns true if approved. */
14
+ export async function confirm(message: string): Promise<boolean> {
15
+ if (autoApprove) return true
16
+
17
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
18
+ return new Promise((resolve) => {
19
+ rl.question(`\x1b[33m⚠ ${message} [y/N] \x1b[0m`, (answer) => {
20
+ rl.close()
21
+ resolve(answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes")
22
+ })
23
+ })
24
+ }
25
+
26
+ /** Check if a shell command is potentially dangerous */
27
+ export function isDangerousCommand(command: string): boolean {
28
+ const dangerous = [
29
+ /\brm\s+(-rf?|--recursive)\s/,
30
+ /\brm\s+-[a-z]*f/,
31
+ /\bsudo\b/,
32
+ /\bmkfs\b/,
33
+ /\bdd\s+/,
34
+ /\b(shutdown|reboot|halt|poweroff)\b/,
35
+ /\bgit\s+(push|reset\s+--hard|clean\s+-[a-z]*f)/,
36
+ /\bnpm\s+publish\b/,
37
+ /\bdrop\s+(table|database)\b/i,
38
+ /\btruncate\s+table\b/i,
39
+ /\bformat\b.*\b[a-z]:\b/i,
40
+ ]
41
+ return dangerous.some((re) => re.test(command))
42
+ }
@@ -0,0 +1,123 @@
1
+ import { readFileSync, existsSync } from "fs"
2
+ import path from "path"
3
+ import os from "os"
4
+ import { getConfigDir, getRulesFile, loadConfig } from "./config.js"
5
+
6
+ /**
7
+ * Instruction/rules system.
8
+ *
9
+ * All config lives under ~/.min-agent/:
10
+ * ~/.min-agent/rules.md — Global rules (always loaded)
11
+ * ~/.min-agent/config.json — Can specify extra "instructions" paths/URLs
12
+ *
13
+ * Project-level rules (auto-discovered from cwd):
14
+ * ./AGENTS.md, ./RULES.md, ./.min-agent/AGENTS.md
15
+ */
16
+
17
+ const PROJECT_FILES = ["AGENTS.md", "RULES.md", "CLAUDE.md"]
18
+
19
+ function findProjectInstructions(): string[] {
20
+ const results: string[] = []
21
+ let current = process.cwd()
22
+ const root = path.parse(current).root
23
+
24
+ while (current !== root) {
25
+ // Check .min-agent/AGENTS.md in project
26
+ const dotDir = path.join(current, ".min-agent", "AGENTS.md")
27
+ if (existsSync(dotDir)) {
28
+ results.push(dotDir)
29
+ break
30
+ }
31
+
32
+ for (const file of PROJECT_FILES) {
33
+ const filepath = path.join(current, file)
34
+ if (existsSync(filepath)) {
35
+ results.push(filepath)
36
+ break
37
+ }
38
+ }
39
+
40
+ if (results.length > 0) break
41
+ current = path.dirname(current)
42
+ }
43
+
44
+ return results
45
+ }
46
+
47
+ function findGlobalRules(): string[] {
48
+ const results: string[] = []
49
+ const rulesFile = getRulesFile()
50
+ if (existsSync(rulesFile)) results.push(rulesFile)
51
+ return results
52
+ }
53
+
54
+ function resolveConfigInstructions(): string[] {
55
+ const config = loadConfig()
56
+ const instructions = config.instructions ?? []
57
+ const results: string[] = []
58
+
59
+ for (const item of instructions) {
60
+ if (item.startsWith("http://") || item.startsWith("https://")) continue
61
+ const resolved = item.startsWith("~/")
62
+ ? path.join(os.homedir(), item.slice(2))
63
+ : path.isAbsolute(item)
64
+ ? item
65
+ : path.resolve(process.cwd(), item)
66
+ if (existsSync(resolved)) results.push(resolved)
67
+ }
68
+
69
+ return results
70
+ }
71
+
72
+ async function fetchRemoteInstructions(): Promise<string[]> {
73
+ const config = loadConfig()
74
+ const instructions = config.instructions ?? []
75
+ const results: string[] = []
76
+
77
+ const urls = instructions.filter((i) => i.startsWith("http://") || i.startsWith("https://"))
78
+ for (const url of urls) {
79
+ try {
80
+ const response = await fetch(url, { signal: AbortSignal.timeout(5000) })
81
+ if (response.ok) {
82
+ const text = await response.text()
83
+ if (text.trim()) results.push(`Instructions from: ${url}\n${text}`)
84
+ }
85
+ } catch {}
86
+ }
87
+
88
+ return results
89
+ }
90
+
91
+ export async function loadInstructions(): Promise<string[]> {
92
+ const parts: string[] = []
93
+
94
+ // Global rules from ~/.min-agent/rules.md
95
+ for (const filepath of findGlobalRules()) {
96
+ try {
97
+ const content = readFileSync(filepath, "utf-8").trim()
98
+ if (content) parts.push(`Instructions from: ${filepath}\n${content}`)
99
+ } catch {}
100
+ }
101
+
102
+ // Project-level instructions
103
+ for (const filepath of findProjectInstructions()) {
104
+ try {
105
+ const content = readFileSync(filepath, "utf-8").trim()
106
+ if (content) parts.push(`Instructions from: ${filepath}\n${content}`)
107
+ } catch {}
108
+ }
109
+
110
+ // Config-defined file instructions
111
+ for (const filepath of resolveConfigInstructions()) {
112
+ try {
113
+ const content = readFileSync(filepath, "utf-8").trim()
114
+ if (content) parts.push(`Instructions from: ${filepath}\n${content}`)
115
+ } catch {}
116
+ }
117
+
118
+ // Remote URL instructions
119
+ const remote = await fetchRemoteInstructions()
120
+ parts.push(...remote)
121
+
122
+ return parts
123
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Lightweight streaming markdown renderer for terminal.
3
+ * Tracks state across text deltas to apply ANSI formatting.
4
+ */
5
+
6
+ function useColor(): boolean {
7
+ if ("NO_COLOR" in process.env) return false
8
+ if (process.env.FORCE_COLOR === "0") return false
9
+ return true
10
+ }
11
+
12
+ const C = {
13
+ reset: "\x1b[0m",
14
+ bold: "\x1b[1m",
15
+ dim: "\x1b[2m",
16
+ italic: "\x1b[3m",
17
+ cyan: "\x1b[36m",
18
+ green: "\x1b[32m",
19
+ yellow: "\x1b[33m",
20
+ magenta: "\x1b[35m",
21
+ gray: "\x1b[90m",
22
+ underline: "\x1b[4m",
23
+ }
24
+
25
+ const Z = {
26
+ reset: "",
27
+ bold: "",
28
+ dim: "",
29
+ italic: "",
30
+ cyan: "",
31
+ green: "",
32
+ yellow: "",
33
+ magenta: "",
34
+ gray: "",
35
+ underline: "",
36
+ }
37
+
38
+ export class MarkdownRenderer {
39
+ private buffer = ""
40
+ private inCodeBlock = false
41
+ private codeLang = ""
42
+
43
+ /** Process a text delta and return formatted output */
44
+ write(text: string): string {
45
+ this.buffer += text
46
+ let output = ""
47
+ const c = useColor() ? C : Z
48
+
49
+ // Process complete lines
50
+ while (true) {
51
+ const nlIdx = this.buffer.indexOf("\n")
52
+ if (nlIdx === -1) break
53
+
54
+ const line = this.buffer.slice(0, nlIdx)
55
+ this.buffer = this.buffer.slice(nlIdx + 1)
56
+ output += this.formatLine(line, c) + "\n"
57
+ }
58
+
59
+ return output
60
+ }
61
+
62
+ /** Flush remaining buffer */
63
+ flush(): string {
64
+ if (!this.buffer) return ""
65
+ const c = useColor() ? C : Z
66
+ const out = this.formatLine(this.buffer, c)
67
+ this.buffer = ""
68
+ return out
69
+ }
70
+
71
+ private formatInline(line: string, c: typeof C): string {
72
+ // Split by inline code spans; format outside segments only
73
+ const parts = line.split(/(`[^`]*`)/g)
74
+ return parts
75
+ .map((seg) => {
76
+ if (seg.startsWith("`") && seg.endsWith("`") && seg.length >= 2) {
77
+ const inner = seg.slice(1, -1)
78
+ return `${c.cyan}${inner}${c.reset}`
79
+ }
80
+ let s = seg
81
+ s = s.replace(/\*\*([^*]+)\*\*/g, `${c.bold}$1${c.reset}`)
82
+ s = s.replace(/\*([^*]+)\*/g, `${c.italic}$1${c.reset}`)
83
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, `${c.cyan}$1${c.reset} ${c.dim}($2)${c.reset}`)
84
+ return s
85
+ })
86
+ .join("")
87
+ }
88
+
89
+ private formatLine(line: string, c: typeof C): string {
90
+ // Code block fence
91
+ if (line.startsWith("```")) {
92
+ if (!this.inCodeBlock) {
93
+ this.inCodeBlock = true
94
+ this.codeLang = line.slice(3).trim()
95
+ return `${c.dim}┌─ ${this.codeLang || "code"} ${"─".repeat(Math.max(0, 40 - (this.codeLang?.length ?? 0)))}${c.reset}`
96
+ }
97
+ this.inCodeBlock = false
98
+ this.codeLang = ""
99
+ return `${c.dim}└${"─".repeat(44)}${c.reset}`
100
+ }
101
+
102
+ // Inside code block — dim
103
+ if (this.inCodeBlock) {
104
+ return `${c.dim}│${c.reset} ${line}`
105
+ }
106
+
107
+ // Headers
108
+ if (line.startsWith("### ")) return `${c.bold}${this.formatInline(line.slice(4), c)}${c.reset}`
109
+ if (line.startsWith("## ")) return `${c.bold}${this.formatInline(line.slice(3), c)}${c.reset}`
110
+ if (line.startsWith("# ")) return `${c.bold}${c.cyan}${this.formatInline(line.slice(2), c)}${c.reset}`
111
+
112
+ // Horizontal rule
113
+ if (/^---+$/.test(line)) return `${c.dim}${"─".repeat(44)}${c.reset}`
114
+
115
+ // Blockquote
116
+ const bq = line.match(/^(\s*)>\s?(.*)$/)
117
+ if (bq) {
118
+ const indent = bq[1]
119
+ const body = bq[2]
120
+ return `${indent}${c.dim}▎${c.reset} ${this.formatInline(body, c)}`
121
+ }
122
+
123
+ // Numbered list (1. item)
124
+ if (/^\s*\d+\.\s/.test(line)) {
125
+ const m = line.match(/^(\s*)(\d+\.)(\s)(.*)$/)
126
+ if (m) {
127
+ return `${m[1]}${c.yellow}${m[2]}${c.reset}${m[3]}${this.formatInline(m[4], c)}`
128
+ }
129
+ }
130
+
131
+ // Bullet points
132
+ if (line.match(/^\s*[-*]\s/)) {
133
+ return line.replace(/^(\s*)([-*])(\s)(.*)$/, (_a, sp, _mark, sp2, rest) => {
134
+ return `${sp}${c.cyan}•${c.reset}${sp2}${this.formatInline(rest, c)}`
135
+ })
136
+ }
137
+
138
+ return this.formatInline(line, c)
139
+ }
140
+ }