@miphamai/cli 0.10.0 → 0.11.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/package.json +1 -1
- package/src/agent/agent-context.ts +60 -1
- package/src/agent/agent-registry.ts +1 -0
- package/src/agent/types.ts +2 -0
- package/src/core/engine.ts +38 -0
- package/src/core/output-styles.ts +95 -0
- package/src/core/rules-loader.ts +175 -0
- package/src/mcp/client.ts +9 -0
- package/src/tools/index.ts +2 -0
- package/src/tools/system/tool-search.ts +143 -0
- package/src/ui/commands.ts +162 -43
package/package.json
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
// apps/cli/src/agent/agent-context.ts
|
|
2
|
+
import { readdirSync, readFileSync, existsSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { homedir } from 'node:os'
|
|
2
5
|
import { ContextManager } from '../core/context'
|
|
3
6
|
import type { ToolDefinition } from '../shared/index.ts'
|
|
4
7
|
import type { AgentDefinition } from './types'
|
|
@@ -8,6 +11,50 @@ export interface AgentContextResult {
|
|
|
8
11
|
allowedTools: ToolDefinition[]
|
|
9
12
|
}
|
|
10
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Load agent memory files from the appropriate scope directory.
|
|
16
|
+
* Returns combined content for injection into the system prompt.
|
|
17
|
+
*/
|
|
18
|
+
function loadAgentMemory(agentName: string, scope: 'user' | 'project' | 'local'): string {
|
|
19
|
+
let memoryDir: string
|
|
20
|
+
const home = homedir()
|
|
21
|
+
|
|
22
|
+
switch (scope) {
|
|
23
|
+
case 'user':
|
|
24
|
+
memoryDir = join(home, '.mipham', 'agent-memory', agentName)
|
|
25
|
+
break
|
|
26
|
+
case 'project':
|
|
27
|
+
memoryDir = join(process.cwd(), '.mipham', 'agent-memory', agentName)
|
|
28
|
+
break
|
|
29
|
+
case 'local':
|
|
30
|
+
memoryDir = join(process.cwd(), '.mipham', 'agent-memory-local', agentName)
|
|
31
|
+
break
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!existsSync(memoryDir)) return ''
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const files = readdirSync(memoryDir).filter((f) => f.endsWith('.md'))
|
|
38
|
+
if (files.length === 0) return ''
|
|
39
|
+
|
|
40
|
+
const contents: string[] = []
|
|
41
|
+
for (const file of files.slice(0, 10)) {
|
|
42
|
+
// max 10 files
|
|
43
|
+
try {
|
|
44
|
+
const content = readFileSync(join(memoryDir, file), 'utf-8').trim()
|
|
45
|
+
if (content) contents.push(content)
|
|
46
|
+
} catch {
|
|
47
|
+
// skip unreadable
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (contents.length === 0) return ''
|
|
52
|
+
return [`[Agent Memory — ${scope} scope]`, ...contents].join('\n\n')
|
|
53
|
+
} catch {
|
|
54
|
+
return ''
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
11
58
|
/**
|
|
12
59
|
* Create an isolated context and tool set for a sub-agent.
|
|
13
60
|
*
|
|
@@ -15,6 +62,9 @@ export interface AgentContextResult {
|
|
|
15
62
|
* 1. If `tools` is set, only those tools are allowed.
|
|
16
63
|
* 2. If `disallowedTools` is set, those are removed from the full set.
|
|
17
64
|
* 3. If neither is set, all tools are available.
|
|
65
|
+
*
|
|
66
|
+
* Agent memory: if agentDef.memory is set, loads memory files from the
|
|
67
|
+
* appropriate scope and injects them into the system prompt.
|
|
18
68
|
*/
|
|
19
69
|
export function createAgentContext(
|
|
20
70
|
agentDef: AgentDefinition,
|
|
@@ -27,7 +77,16 @@ export function createAgentContext(
|
|
|
27
77
|
compactionThreshold: 0.85,
|
|
28
78
|
})
|
|
29
79
|
|
|
30
|
-
|
|
80
|
+
// Build system prompt with optional agent memory
|
|
81
|
+
let systemPrompt = agentDef.systemPrompt
|
|
82
|
+
if (agentDef.memory) {
|
|
83
|
+
const memory = loadAgentMemory(agentDef.name, agentDef.memory)
|
|
84
|
+
if (memory) {
|
|
85
|
+
systemPrompt = `${systemPrompt}\n\n---\n\n${memory}`
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
context.setSystemPrompt(systemPrompt)
|
|
31
90
|
|
|
32
91
|
// Scope tools
|
|
33
92
|
let allowedTools = Array.from(toolRegistry.values())
|
package/src/agent/types.ts
CHANGED
|
@@ -12,6 +12,7 @@ export interface AgentFrontmatter {
|
|
|
12
12
|
maxTurns?: number
|
|
13
13
|
skills?: string
|
|
14
14
|
background?: boolean
|
|
15
|
+
memory?: 'user' | 'project' | 'local' // agent memory scope
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
export interface AgentDefinition {
|
|
@@ -27,6 +28,7 @@ export interface AgentDefinition {
|
|
|
27
28
|
background: boolean
|
|
28
29
|
source: 'builtin' | 'project' | 'user'
|
|
29
30
|
filePath?: string
|
|
31
|
+
memory?: 'user' | 'project' | 'local' // agent memory scope
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
export interface SubAgentOptions {
|
package/src/core/engine.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { getMemoryManager } from './memory/memory-loader'
|
|
|
10
10
|
import type { AgentViewManager } from '../agent-view/agent-view-manager'
|
|
11
11
|
import type { SkillsLoader } from '../skills/loader'
|
|
12
12
|
import { getBackgroundAgentRegistry } from '../agent/background-registry'
|
|
13
|
+
import { RulesLoader } from './rules-loader'
|
|
13
14
|
|
|
14
15
|
export class QueryEngine {
|
|
15
16
|
private hookEngine?: HookEngine
|
|
@@ -71,9 +72,40 @@ export class QueryEngine {
|
|
|
71
72
|
this.skillsLoader = loader
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
/** Rules loader for path-scoped rules injection. */
|
|
76
|
+
private rulesLoader?: RulesLoader
|
|
77
|
+
/** Files touched in the current turn (for rules matching). */
|
|
78
|
+
private touchedFiles: Set<string> = new Set()
|
|
79
|
+
|
|
80
|
+
/** Register the rules loader. */
|
|
81
|
+
setRulesLoader(loader: RulesLoader): void {
|
|
82
|
+
this.rulesLoader = loader
|
|
83
|
+
this.rulesLoader.load()
|
|
84
|
+
}
|
|
85
|
+
|
|
74
86
|
/** Pending task notifications from background agents (cleared after draining). */
|
|
75
87
|
private pendingTaskNotifications: Array<StreamChunk> = []
|
|
76
88
|
|
|
89
|
+
/** Track files touched by tools for rules matching. */
|
|
90
|
+
private trackTouchedFile(toolName: string, params: Record<string, unknown>): void {
|
|
91
|
+
const fileTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep']
|
|
92
|
+
if (!fileTools.includes(toolName)) return
|
|
93
|
+
const filePath = (params.file_path || params.path || params.file) as string | undefined
|
|
94
|
+
if (filePath && typeof filePath === 'string') {
|
|
95
|
+
this.touchedFiles.add(filePath)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Inject matching rules as context after tool execution. */
|
|
100
|
+
private injectRules(): void {
|
|
101
|
+
if (!this.rulesLoader || this.touchedFiles.size === 0) return
|
|
102
|
+
const files = Array.from(this.touchedFiles)
|
|
103
|
+
const block = this.rulesLoader.buildContextBlock(files)
|
|
104
|
+
if (!block) return
|
|
105
|
+
this.context.addMessage({ role: 'user', content: block })
|
|
106
|
+
this.touchedFiles.clear()
|
|
107
|
+
}
|
|
108
|
+
|
|
77
109
|
/**
|
|
78
110
|
* Drain pending background task notifications.
|
|
79
111
|
* Call this after tool execution to surface completed/failed background agent results.
|
|
@@ -351,6 +383,9 @@ export class QueryEngine {
|
|
|
351
383
|
})
|
|
352
384
|
}
|
|
353
385
|
|
|
386
|
+
// Inject path-scoped rules for touched files
|
|
387
|
+
this.injectRules()
|
|
388
|
+
|
|
354
389
|
// Drain task notifications after tool execution
|
|
355
390
|
for (const chunk of this.drainTaskNotifications()) {
|
|
356
391
|
yield chunk
|
|
@@ -633,6 +668,9 @@ export class QueryEngine {
|
|
|
633
668
|
backgroundAgentRegistry: getBackgroundAgentRegistry(),
|
|
634
669
|
})
|
|
635
670
|
|
|
671
|
+
// Track touched files for rules matching
|
|
672
|
+
this.trackTouchedFile(name, effectiveParams)
|
|
673
|
+
|
|
636
674
|
// Run PostToolUse hooks
|
|
637
675
|
if (this.hookEngine) {
|
|
638
676
|
await this.hookEngine.executePostToolUse(name, effectiveParams, result, 'session-1')
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OutputStylesLoader — loads custom response persona styles from .mipham/output-styles/.
|
|
3
|
+
*
|
|
4
|
+
* Each .md file in the directory is a named style. The active style's content
|
|
5
|
+
* is injected into the system prompt to shape the AI's tone and communication style.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const loader = new OutputStylesLoader(cwd)
|
|
9
|
+
* loader.list() // ['concise', 'academic', ...]
|
|
10
|
+
* loader.get('concise') // style content or undefined
|
|
11
|
+
* loader.setActive('concise') // switch active style
|
|
12
|
+
* loader.getActiveContent() // active style content (for system prompt)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readdirSync, readFileSync, existsSync } from 'node:fs'
|
|
16
|
+
import { join, basename, extname } from 'node:path'
|
|
17
|
+
|
|
18
|
+
export class OutputStylesLoader {
|
|
19
|
+
private stylesDir: string
|
|
20
|
+
private activeStyle: string | null = null
|
|
21
|
+
|
|
22
|
+
constructor(cwd: string) {
|
|
23
|
+
this.stylesDir = join(cwd, '.mipham', 'output-styles')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* List all available style names (filename without .md extension).
|
|
28
|
+
*/
|
|
29
|
+
list(): string[] {
|
|
30
|
+
if (!existsSync(this.stylesDir)) return []
|
|
31
|
+
try {
|
|
32
|
+
return readdirSync(this.stylesDir)
|
|
33
|
+
.filter((f) => f.endsWith('.md'))
|
|
34
|
+
.map((f) => basename(f, extname(f)))
|
|
35
|
+
} catch {
|
|
36
|
+
return []
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get the content of a named style. Returns undefined if not found.
|
|
42
|
+
*/
|
|
43
|
+
get(name: string): string | undefined {
|
|
44
|
+
if (!existsSync(this.stylesDir)) return undefined
|
|
45
|
+
const filepath = join(this.stylesDir, `${name}.md`)
|
|
46
|
+
if (!existsSync(filepath)) return undefined
|
|
47
|
+
try {
|
|
48
|
+
return readFileSync(filepath, 'utf-8').trim()
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Set the active output style.
|
|
56
|
+
*/
|
|
57
|
+
setActive(name: string): boolean {
|
|
58
|
+
const content = this.get(name)
|
|
59
|
+
if (!content) return false
|
|
60
|
+
this.activeStyle = name
|
|
61
|
+
return true
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Clear the active output style (revert to default personality).
|
|
66
|
+
*/
|
|
67
|
+
clearActive(): void {
|
|
68
|
+
this.activeStyle = null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get the active style name (null if none selected).
|
|
73
|
+
*/
|
|
74
|
+
getActiveName(): string | null {
|
|
75
|
+
return this.activeStyle
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get the active style content for system prompt injection.
|
|
80
|
+
* Returns empty string if no style is active.
|
|
81
|
+
*/
|
|
82
|
+
getActiveContent(): string {
|
|
83
|
+
if (!this.activeStyle) return ''
|
|
84
|
+
const content = this.get(this.activeStyle)
|
|
85
|
+
if (!content) return ''
|
|
86
|
+
return [
|
|
87
|
+
`[Output Style: ${this.activeStyle}]`,
|
|
88
|
+
'Adopt the following communication style in all your responses:',
|
|
89
|
+
'',
|
|
90
|
+
content,
|
|
91
|
+
'',
|
|
92
|
+
'---',
|
|
93
|
+
].join('\n')
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RulesLoader — path-scoped rules from .mipham/rules/.
|
|
3
|
+
*
|
|
4
|
+
* Rules are markdown files with YAML frontmatter. They are injected into
|
|
5
|
+
* the conversation when the AI touches matching files.
|
|
6
|
+
*
|
|
7
|
+
* Directory structure:
|
|
8
|
+
* .mipham/rules/
|
|
9
|
+
* always.md — always loaded (no paths filter)
|
|
10
|
+
* typescript.md — loaded when touching *.ts files
|
|
11
|
+
* security.md — loaded when touching auth/ or crypto/ paths
|
|
12
|
+
*
|
|
13
|
+
* Frontmatter:
|
|
14
|
+
* ---
|
|
15
|
+
* paths: "apps/cli/src/**\/*.ts"
|
|
16
|
+
* description: TypeScript coding standards
|
|
17
|
+
* ---
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readdirSync, readFileSync, existsSync } from 'node:fs'
|
|
21
|
+
import { join, relative } from 'node:path'
|
|
22
|
+
|
|
23
|
+
interface RuleFile {
|
|
24
|
+
name: string
|
|
25
|
+
paths: string[] // glob patterns, empty = always loaded
|
|
26
|
+
description: string
|
|
27
|
+
content: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class RulesLoader {
|
|
31
|
+
private rules: RuleFile[] = []
|
|
32
|
+
private rulesDir: string
|
|
33
|
+
|
|
34
|
+
constructor(cwd: string) {
|
|
35
|
+
this.rulesDir = join(cwd, '.mipham', 'rules')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Load all rules from .mipham/rules/. Call once at startup.
|
|
40
|
+
*/
|
|
41
|
+
load(): void {
|
|
42
|
+
this.rules = []
|
|
43
|
+
if (!existsSync(this.rulesDir)) return
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const files = readdirSync(this.rulesDir).filter((f) => f.endsWith('.md'))
|
|
47
|
+
for (const file of files) {
|
|
48
|
+
const filepath = join(this.rulesDir, file)
|
|
49
|
+
try {
|
|
50
|
+
const raw = readFileSync(filepath, 'utf-8')
|
|
51
|
+
const { paths, description, content } = this.parseRule(raw, file)
|
|
52
|
+
this.rules.push({ name: file.replace(/\.md$/, ''), paths, description, content })
|
|
53
|
+
} catch {
|
|
54
|
+
// Skip unparseable files
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// Directory read error — rules unavailable
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get rules that match the given file paths.
|
|
64
|
+
* Rules with no paths filter ("always") are always included.
|
|
65
|
+
*/
|
|
66
|
+
getMatchingRules(touchedFiles: string[]): RuleFile[] {
|
|
67
|
+
const matched: RuleFile[] = []
|
|
68
|
+
|
|
69
|
+
for (const rule of this.rules) {
|
|
70
|
+
// Always rules — no paths filter
|
|
71
|
+
if (rule.paths.length === 0) {
|
|
72
|
+
matched.push(rule)
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Check if any touched file matches any rule path pattern
|
|
77
|
+
for (const file of touchedFiles) {
|
|
78
|
+
for (const pattern of rule.paths) {
|
|
79
|
+
if (this.matchPath(file, pattern)) {
|
|
80
|
+
matched.push(rule)
|
|
81
|
+
// Break inner loops — rule already matched
|
|
82
|
+
break
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (matched.includes(rule)) break
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return matched
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build a context block to inject into the conversation.
|
|
94
|
+
*/
|
|
95
|
+
buildContextBlock(touchedFiles: string[]): string {
|
|
96
|
+
const matched = this.getMatchingRules(touchedFiles)
|
|
97
|
+
if (matched.length === 0) return ''
|
|
98
|
+
|
|
99
|
+
const blocks = matched.map(
|
|
100
|
+
(r) => `[Rule: ${r.name}]${r.description ? ` — ${r.description}` : ''}\n${r.content}`,
|
|
101
|
+
)
|
|
102
|
+
return `\n<!-- Path-scoped rules matching: ${touchedFiles.join(', ')} -->\n${blocks.join('\n\n')}\n`
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Count loaded rules.
|
|
107
|
+
*/
|
|
108
|
+
count(): number {
|
|
109
|
+
return this.rules.length
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* List loaded rule names.
|
|
114
|
+
*/
|
|
115
|
+
list(): string[] {
|
|
116
|
+
return this.rules.map((r) => r.name)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Simple glob matching. Supports **, *, and exact file matching.
|
|
121
|
+
* Returns true if file matches pattern.
|
|
122
|
+
*/
|
|
123
|
+
private matchPath(file: string, pattern: string): boolean {
|
|
124
|
+
// Convert glob pattern to regex
|
|
125
|
+
let regexStr = pattern
|
|
126
|
+
.replace(/\./g, '\\.')
|
|
127
|
+
.replace(/\*\*/g, '<<GLOBSTAR>>')
|
|
128
|
+
.replace(/\*/g, '[^/]*')
|
|
129
|
+
.replace(/<<GLOBSTAR>>/g, '.*')
|
|
130
|
+
|
|
131
|
+
// If pattern doesn't start with ** or *, anchor to be a suffix match
|
|
132
|
+
if (!pattern.startsWith('**') && !pattern.startsWith('*')) {
|
|
133
|
+
regexStr = regexStr + '$'
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
return new RegExp(regexStr).test(file)
|
|
138
|
+
} catch {
|
|
139
|
+
// Invalid pattern — fallback to simple includes
|
|
140
|
+
return file.includes(pattern.replace(/\*\*/g, '').replace(/\*/g, ''))
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Parse a rule file — extract frontmatter and body.
|
|
146
|
+
*/
|
|
147
|
+
private parseRule(
|
|
148
|
+
raw: string,
|
|
149
|
+
filename: string,
|
|
150
|
+
): { paths: string[]; description: string; content: string } {
|
|
151
|
+
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
|
|
152
|
+
if (!match) {
|
|
153
|
+
return { paths: [], description: '', content: raw.trim() }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const frontmatter = match[1] || ''
|
|
157
|
+
const body = (match[2] || '').trim()
|
|
158
|
+
|
|
159
|
+
const paths: string[] = []
|
|
160
|
+
let description = ''
|
|
161
|
+
|
|
162
|
+
for (const line of frontmatter.split('\n')) {
|
|
163
|
+
const pathMatch = line.match(/^paths:\s*"(.+)"$/)
|
|
164
|
+
if (pathMatch) {
|
|
165
|
+
pathMatch[1]!.split(',').forEach((p) => paths.push(p.trim()))
|
|
166
|
+
}
|
|
167
|
+
const descMatch = line.match(/^description:\s*(.+)$/)
|
|
168
|
+
if (descMatch) {
|
|
169
|
+
description = descMatch[1]!.trim()
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { paths, description, content: body }
|
|
174
|
+
}
|
|
175
|
+
}
|
package/src/mcp/client.ts
CHANGED
|
@@ -151,6 +151,15 @@ export class McpClient {
|
|
|
151
151
|
return this.connections.get(name)?.tools || []
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/** List all currently connected MCP server names. */
|
|
155
|
+
getConnectedServers(): string[] {
|
|
156
|
+
const names: string[] = []
|
|
157
|
+
for (const [name, conn] of this.connections) {
|
|
158
|
+
if (conn.status === 'connected') names.push(name)
|
|
159
|
+
}
|
|
160
|
+
return names
|
|
161
|
+
}
|
|
162
|
+
|
|
154
163
|
async callTool(
|
|
155
164
|
serverName: string,
|
|
156
165
|
toolName: string,
|
package/src/tools/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { webFetchTool } from './network/web-fetch'
|
|
|
20
20
|
import { webSearchTool } from './network/web-search'
|
|
21
21
|
import { configTool } from './system/config'
|
|
22
22
|
import { mcpTool } from './system/mcp'
|
|
23
|
+
import { toolSearchTool } from './system/tool-search'
|
|
23
24
|
import { artifactTool } from './artifact/artifact'
|
|
24
25
|
import { reportFindingsTool } from './agent/report-findings'
|
|
25
26
|
import { sendMessageTool } from './agent/send-message'
|
|
@@ -134,6 +135,7 @@ export function createToolRegistry(): Map<string, ToolDefinition> {
|
|
|
134
135
|
// System tools
|
|
135
136
|
withValidation(configTool),
|
|
136
137
|
withValidation(mcpTool),
|
|
138
|
+
withValidation(toolSearchTool),
|
|
137
139
|
// Artifact tools
|
|
138
140
|
withValidation(artifactTool),
|
|
139
141
|
// Computer Use tools
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ToolSearch — search MCP server tools by name or description.
|
|
3
|
+
*
|
|
4
|
+
* Instead of loading all MCP tool definitions into the context upfront,
|
|
5
|
+
* the AI can call ToolSearch to discover relevant tools on demand.
|
|
6
|
+
* This saves ~85% of context tokens for large MCP servers (50-100 tools).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ToolDefinition } from '../../shared/index.ts'
|
|
10
|
+
import { McpClient } from '../../mcp/client'
|
|
11
|
+
|
|
12
|
+
export const toolSearchTool: ToolDefinition = {
|
|
13
|
+
name: 'ToolSearch',
|
|
14
|
+
description:
|
|
15
|
+
'Search available MCP server tools by name or description. ' +
|
|
16
|
+
'Use this to discover tools on demand instead of loading all tool definitions into context. ' +
|
|
17
|
+
'Returns matching tool names with their server and description.',
|
|
18
|
+
category: 'system',
|
|
19
|
+
permission: 'auto',
|
|
20
|
+
parameters: {
|
|
21
|
+
type: 'object',
|
|
22
|
+
properties: {
|
|
23
|
+
query: {
|
|
24
|
+
type: 'string',
|
|
25
|
+
description:
|
|
26
|
+
'Search query — matches against tool names and descriptions. Leave empty to list all available MCP tools.',
|
|
27
|
+
},
|
|
28
|
+
server: {
|
|
29
|
+
type: 'string',
|
|
30
|
+
description: 'Optional: limit search to a specific MCP server name.',
|
|
31
|
+
},
|
|
32
|
+
limit: {
|
|
33
|
+
type: 'integer',
|
|
34
|
+
description: 'Max results to return (default: 20).',
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
required: [],
|
|
38
|
+
},
|
|
39
|
+
async execute(params, _ctx) {
|
|
40
|
+
const query = ((params.query as string) || '').toLowerCase()
|
|
41
|
+
const serverFilter = (params.server as string) || ''
|
|
42
|
+
const limit = (params.limit as number) || 20
|
|
43
|
+
|
|
44
|
+
const client = McpClient.getInstance()
|
|
45
|
+
const connectedServers = client.getConnectedServers()
|
|
46
|
+
|
|
47
|
+
if (connectedServers.length === 0) {
|
|
48
|
+
return {
|
|
49
|
+
success: true,
|
|
50
|
+
content:
|
|
51
|
+
'── MCP Tool Search ──\n\n' +
|
|
52
|
+
'No MCP servers connected.\n\n' +
|
|
53
|
+
'Connect MCP servers in .mcp.json or via /mcp add <name> <url>.',
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Filter by server if specified
|
|
58
|
+
const servers = serverFilter
|
|
59
|
+
? connectedServers.filter((s) => s.toLowerCase().includes(serverFilter))
|
|
60
|
+
: connectedServers
|
|
61
|
+
|
|
62
|
+
if (serverFilter && servers.length === 0) {
|
|
63
|
+
return {
|
|
64
|
+
success: true,
|
|
65
|
+
content:
|
|
66
|
+
`── MCP Tool Search ──\n\n` +
|
|
67
|
+
`No connected server matching "${serverFilter}".\n\n` +
|
|
68
|
+
`Connected servers: ${connectedServers.join(', ')}`,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Collect matching tools
|
|
73
|
+
interface Match {
|
|
74
|
+
server: string
|
|
75
|
+
tool: string
|
|
76
|
+
description: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const matches: Match[] = []
|
|
80
|
+
|
|
81
|
+
for (const serverName of servers) {
|
|
82
|
+
try {
|
|
83
|
+
const tools = client.getTools(serverName)
|
|
84
|
+
for (const tool of tools) {
|
|
85
|
+
const nameMatch = !query || tool.name.toLowerCase().includes(query)
|
|
86
|
+
const descMatch = !query || (tool.description || '').toLowerCase().includes(query)
|
|
87
|
+
if (nameMatch || descMatch) {
|
|
88
|
+
matches.push({
|
|
89
|
+
server: serverName,
|
|
90
|
+
tool: tool.name,
|
|
91
|
+
description: (tool.description || '').slice(0, 120),
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// Server disconnected mid-search — skip
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (matches.length === 0) {
|
|
101
|
+
return {
|
|
102
|
+
success: true,
|
|
103
|
+
content:
|
|
104
|
+
`── MCP Tool Search ──\n\n` +
|
|
105
|
+
`No tools matching "${query}" across ${servers.length} server(s).\n\n` +
|
|
106
|
+
`Try a different query, or omit the query to list all tools.`,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Truncate to limit
|
|
111
|
+
const limited = matches.slice(0, limit)
|
|
112
|
+
|
|
113
|
+
const lines: string[] = [
|
|
114
|
+
`── MCP Tool Search ──`,
|
|
115
|
+
'',
|
|
116
|
+
`${limited.length} of ${matches.length} matching tool(s) across ${servers.length} server(s):`,
|
|
117
|
+
'',
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
// Group by server
|
|
121
|
+
const byServer = new Map<string, Match[]>()
|
|
122
|
+
for (const m of limited) {
|
|
123
|
+
const list = byServer.get(m.server) || []
|
|
124
|
+
list.push(m)
|
|
125
|
+
byServer.set(m.server, list)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const [server, tools] of byServer) {
|
|
129
|
+
lines.push(` ${server} (${tools.length}):`)
|
|
130
|
+
for (const t of tools) {
|
|
131
|
+
const desc = t.description ? ` — ${t.description}` : ''
|
|
132
|
+
lines.push(` mcp__${server.replace(/[^a-z0-9-]/g, '_')}__${t.tool}${desc}`)
|
|
133
|
+
}
|
|
134
|
+
lines.push('')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (matches.length > limit) {
|
|
138
|
+
lines.push(`... and ${matches.length - limit} more. Use "query" to narrow results.`)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { success: true, content: lines.join('\n') }
|
|
142
|
+
},
|
|
143
|
+
}
|
package/src/ui/commands.ts
CHANGED
|
@@ -3182,64 +3182,183 @@ Full changelog: https://mipham.ai/code/releases`,
|
|
|
3182
3182
|
// IDE — IDE integration guide
|
|
3183
3183
|
// ═══════════════════════════════════════════════════════════════
|
|
3184
3184
|
|
|
3185
|
-
const ideCmd: CommandHandler = () =>
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
VS Code:
|
|
3189
|
-
Install the Mipham Code extension from the VS Code marketplace.
|
|
3190
|
-
• Open Command Palette (Cmd+Shift+P)
|
|
3191
|
-
• Search "Mipham Code: Start"
|
|
3192
|
-
• The terminal panel opens with Mipham Code loaded
|
|
3185
|
+
const ideCmd: CommandHandler = async (_ctx) => {
|
|
3186
|
+
const { mkdirSync, writeFileSync, existsSync } = await import('node:fs')
|
|
3187
|
+
const { join } = await import('node:path')
|
|
3193
3188
|
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
"mipham": { "path": "bun", "args": ["run", "mipham"] }
|
|
3198
|
-
}
|
|
3199
|
-
}
|
|
3189
|
+
const cwd = process.cwd()
|
|
3190
|
+
const vscodeDir = join(cwd, '.vscode')
|
|
3191
|
+
mkdirSync(vscodeDir, { recursive: true })
|
|
3200
3192
|
|
|
3201
|
-
|
|
3202
|
-
• Settings → Tools → Terminal → Shell path
|
|
3203
|
-
• Set to: bun run ~/path/to/mipham-code/apps/cli/bin/mipham
|
|
3193
|
+
const files: string[] = []
|
|
3204
3194
|
|
|
3205
|
-
|
|
3206
|
-
|
|
3195
|
+
// ── Detect bun path ──
|
|
3196
|
+
let bunPath = '/opt/homebrew/bin/bun'
|
|
3197
|
+
try {
|
|
3198
|
+
const { execSync } = await import('node:child_process')
|
|
3199
|
+
const detected = execSync('which bun 2>/dev/null || echo /opt/homebrew/bin/bun', {
|
|
3200
|
+
encoding: 'utf-8',
|
|
3201
|
+
}).trim()
|
|
3202
|
+
if (detected) bunPath = detected
|
|
3203
|
+
} catch {
|
|
3204
|
+
// Use default
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
// ── settings.json: terminal profile ──
|
|
3208
|
+
const settingsPath = join(vscodeDir, 'settings.json')
|
|
3209
|
+
const settings = {
|
|
3210
|
+
'terminal.integrated.profiles.osx': {
|
|
3211
|
+
mipham: {
|
|
3212
|
+
path: bunPath,
|
|
3213
|
+
args: ['run', 'mipham'],
|
|
3214
|
+
cwd: '${workspaceFolder}',
|
|
3215
|
+
},
|
|
3216
|
+
},
|
|
3217
|
+
}
|
|
3218
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8')
|
|
3219
|
+
files.push('.vscode/settings.json')
|
|
3220
|
+
|
|
3221
|
+
// ── keybindings.json: Cmd+Esc launch ──
|
|
3222
|
+
const keybindingsPath = join(vscodeDir, 'keybindings.json')
|
|
3223
|
+
const keybindings = [
|
|
3224
|
+
{
|
|
3225
|
+
key: 'cmd+escape',
|
|
3226
|
+
command: 'workbench.action.terminal.focus',
|
|
3227
|
+
when: 'terminalProcessSupported',
|
|
3228
|
+
},
|
|
3229
|
+
{
|
|
3230
|
+
key: 'cmd+shift+m',
|
|
3231
|
+
command: 'workbench.action.terminal.new',
|
|
3232
|
+
},
|
|
3233
|
+
]
|
|
3234
|
+
writeFileSync(keybindingsPath, JSON.stringify(keybindings, null, 2) + '\n', 'utf-8')
|
|
3235
|
+
files.push('.vscode/keybindings.json')
|
|
3207
3236
|
|
|
3208
|
-
|
|
3237
|
+
// ── extensions.json: recommended ──
|
|
3238
|
+
const extensionsPath = join(vscodeDir, 'extensions.json')
|
|
3239
|
+
const extensions = {
|
|
3240
|
+
recommendations: ['miphamai.mipham-code'],
|
|
3241
|
+
}
|
|
3242
|
+
writeFileSync(extensionsPath, JSON.stringify(extensions, null, 2) + '\n', 'utf-8')
|
|
3243
|
+
files.push('.vscode/extensions.json')
|
|
3209
3244
|
|
|
3210
|
-
|
|
3211
|
-
|
|
3245
|
+
return {
|
|
3246
|
+
content: [
|
|
3247
|
+
'── VS Code Integration ──',
|
|
3248
|
+
'',
|
|
3249
|
+
`Generated in ${vscodeDir}:`,
|
|
3250
|
+
...files.map((f) => ` ✅ ${f}`),
|
|
3251
|
+
'',
|
|
3252
|
+
'What was configured:',
|
|
3253
|
+
' • Terminal profile "mipham" — opens Mipham Code in integrated terminal',
|
|
3254
|
+
' • Keyboard shortcut Cmd+Esc — focus terminal',
|
|
3255
|
+
' • Cmd+Shift+M — new terminal',
|
|
3256
|
+
'',
|
|
3257
|
+
'To use:',
|
|
3258
|
+
' 1. Restart VS Code (or reload window: Cmd+Shift+P → Reload Window)',
|
|
3259
|
+
' 2. Open terminal: Ctrl+` or Cmd+Esc',
|
|
3260
|
+
' 3. Select "mipham" profile from the terminal dropdown',
|
|
3261
|
+
'',
|
|
3262
|
+
'Install the VS Code extension for full integration:',
|
|
3263
|
+
` code --install-extension miphamai.mipham-code`,
|
|
3264
|
+
'',
|
|
3265
|
+
'JetBrains: Settings → Tools → Terminal → Shell path → bun run mipham',
|
|
3266
|
+
].join('\n'),
|
|
3267
|
+
}
|
|
3268
|
+
}
|
|
3212
3269
|
|
|
3213
3270
|
// ═══════════════════════════════════════════════════════════════
|
|
3214
3271
|
// Terminal Setup — shell integration guide
|
|
3215
3272
|
// ═══════════════════════════════════════════════════════════════
|
|
3216
3273
|
|
|
3217
|
-
const terminalSetupCmd: CommandHandler = () =>
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
${NPM_INSTALL_COMMAND}
|
|
3222
|
-
mipham
|
|
3223
|
-
|
|
3224
|
-
One-liner install:
|
|
3225
|
-
curl -fsSL https://mipham.ai/install.sh | bash
|
|
3274
|
+
const terminalSetupCmd: CommandHandler = async () => {
|
|
3275
|
+
const { writeFileSync, appendFileSync, existsSync, mkdirSync } = await import('node:fs')
|
|
3276
|
+
const { join } = await import('node:path')
|
|
3277
|
+
const { homedir } = await import('node:os')
|
|
3226
3278
|
|
|
3227
|
-
|
|
3228
|
-
|
|
3279
|
+
const home = homedir()
|
|
3280
|
+
const lines: string[] = ['── Terminal Setup ──', '']
|
|
3281
|
+
|
|
3282
|
+
// ── 1. Generate standalone shell setup script ──
|
|
3283
|
+
const miphamDir = join(home, '.mipham')
|
|
3284
|
+
mkdirSync(miphamDir, { recursive: true })
|
|
3285
|
+
|
|
3286
|
+
const shellScript = join(miphamDir, 'shell-setup.sh')
|
|
3287
|
+
const shellContent = [
|
|
3288
|
+
'#!/bin/bash',
|
|
3289
|
+
'# Mipham Code — Shell Integration',
|
|
3290
|
+
'# Source this file in your shell profile:',
|
|
3291
|
+
'# source ~/.mipham/shell-setup.sh',
|
|
3292
|
+
'',
|
|
3293
|
+
`export MIPHAM_HOME="${home}/.mipham"`,
|
|
3294
|
+
'',
|
|
3295
|
+
'# Alias: launch Mipham Code in current directory',
|
|
3296
|
+
`alias mipham='cd $(pwd) && ${NPM_INSTALL_COMMAND} > /dev/null 2>&1; mipham'`,
|
|
3297
|
+
'',
|
|
3298
|
+
'# Or use the local development version:',
|
|
3299
|
+
'# alias mipham="bun run /path/to/mipham-code/apps/cli/bin/mipham"',
|
|
3300
|
+
'',
|
|
3301
|
+
'# Auto-detect provider from config',
|
|
3302
|
+
'if [ -f ~/.mipham/config.yml ]; then',
|
|
3303
|
+
' export MIPHAM_PROVIDER=$(grep "defaultProvider:" ~/.mipham/config.yml | awk "{print \$2}")',
|
|
3304
|
+
'fi',
|
|
3305
|
+
].join('\n')
|
|
3306
|
+
writeFileSync(shellScript, shellContent + '\n', 'utf-8')
|
|
3307
|
+
lines.push(` ✅ Generated: ${shellScript}`)
|
|
3308
|
+
|
|
3309
|
+
// ── 2. Append to shell profile ──
|
|
3310
|
+
const shell = process.env.SHELL || '/bin/zsh'
|
|
3311
|
+
const profileName = shell.includes('zsh') ? '.zshrc' : '.bashrc'
|
|
3312
|
+
const profilePath = join(home, profileName)
|
|
3313
|
+
const sourceLine = `\n# Mipham Code shell integration\n[ -f ~/.mipham/shell-setup.sh ] && source ~/.mipham/shell-setup.sh\n`
|
|
3229
3314
|
|
|
3230
|
-
|
|
3231
|
-
|
|
3315
|
+
try {
|
|
3316
|
+
const existing = existsSync(profilePath)
|
|
3317
|
+
? require('node:fs').readFileSync(profilePath, 'utf-8')
|
|
3318
|
+
: ''
|
|
3319
|
+
if (existing.includes('shell-setup.sh')) {
|
|
3320
|
+
lines.push(` ⏭ ${profileName} already has Mipham Code integration`)
|
|
3321
|
+
} else {
|
|
3322
|
+
appendFileSync(profilePath, sourceLine, 'utf-8')
|
|
3323
|
+
lines.push(` ✅ Added to ~/${profileName}`)
|
|
3324
|
+
}
|
|
3325
|
+
} catch {
|
|
3326
|
+
lines.push(` ⚠️ Could not update ~/${profileName}. Add manually:`)
|
|
3327
|
+
lines.push(` echo '${sourceLine.trim()}' >> ~/${profileName}`)
|
|
3328
|
+
}
|
|
3232
3329
|
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3330
|
+
// ── 3. Global install check ──
|
|
3331
|
+
lines.push('')
|
|
3332
|
+
lines.push('── Installation ──')
|
|
3333
|
+
try {
|
|
3334
|
+
const { execSync } = await import('node:child_process')
|
|
3335
|
+
const miphamPath = execSync('which mipham 2>/dev/null || echo ""', { encoding: 'utf-8' }).trim()
|
|
3336
|
+
if (miphamPath) {
|
|
3337
|
+
lines.push(` ✅ mipham found at: ${miphamPath}`)
|
|
3338
|
+
const version = execSync('mipham --version 2>/dev/null || echo "unknown"', {
|
|
3339
|
+
encoding: 'utf-8',
|
|
3340
|
+
}).trim()
|
|
3341
|
+
lines.push(` 📦 Version: ${version}`)
|
|
3342
|
+
} else {
|
|
3343
|
+
lines.push(` ⚠️ mipham not in PATH. Install globally:`)
|
|
3344
|
+
lines.push(` ${NPM_INSTALL_COMMAND}`)
|
|
3345
|
+
lines.push(` or: curl -fsSL https://mipham.ai/install.sh | bash`)
|
|
3346
|
+
}
|
|
3347
|
+
} catch {
|
|
3348
|
+
lines.push(` 💡 Install: ${NPM_INSTALL_COMMAND}`)
|
|
3349
|
+
}
|
|
3236
3350
|
|
|
3237
|
-
Verify
|
|
3238
|
-
|
|
3239
|
-
|
|
3351
|
+
// ── 4. Verify ──
|
|
3352
|
+
lines.push('')
|
|
3353
|
+
lines.push('── Next Steps ──')
|
|
3354
|
+
lines.push(' 1. Restart your terminal or run: source ~/.mipham/shell-setup.sh')
|
|
3355
|
+
lines.push(' 2. Run: mipham --version')
|
|
3356
|
+
lines.push(' 3. Start coding: cd your-project && mipham')
|
|
3357
|
+
lines.push('')
|
|
3358
|
+
lines.push(`Works with: Zsh, Bash. Shell: ${shell}`)
|
|
3240
3359
|
|
|
3241
|
-
|
|
3242
|
-
}
|
|
3360
|
+
return { content: lines.join('\n') }
|
|
3361
|
+
}
|
|
3243
3362
|
|
|
3244
3363
|
// ═══════════════════════════════════════════════════════════════
|
|
3245
3364
|
// Phase 4 — MCP Server Management
|