@miphamai/cli 0.3.0 → 0.5.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.
Files changed (74) hide show
  1. package/bin/mipham.ts +188 -0
  2. package/dist/mipham +0 -0
  3. package/package.json +9 -9
  4. package/skills/mipham/om-artifact.mipham-skill.md +69 -0
  5. package/skills/mipham/om-model-optimize.mipham-skill.md +9 -6
  6. package/skills/mipham/om-security.mipham-skill.md +11 -8
  7. package/skills/standard/doc-generator.SKILL.md +6 -1
  8. package/skills/standard/github-ops.SKILL.md +12 -7
  9. package/skills/standard/memory.SKILL.md +1 -0
  10. package/skills/standard/self-review.SKILL.md +1 -0
  11. package/skills/standard/superpower.SKILL.md +7 -7
  12. package/skills/standard/web-search.SKILL.md +3 -0
  13. package/src/agent/agent-context.ts +56 -0
  14. package/src/agent/agent-registry.ts +134 -0
  15. package/src/agent/sub-agent.ts +73 -89
  16. package/src/agent/types.ts +39 -0
  17. package/src/agent-view/agent-view-manager.ts +217 -0
  18. package/src/agent-view/dashboard.tsx +218 -0
  19. package/src/agent-view/session-peek.tsx +72 -0
  20. package/src/agent-view/session-row.tsx +70 -0
  21. package/src/artifacts/manifest.ts +101 -0
  22. package/src/artifacts/server.ts +374 -0
  23. package/src/artifacts/versioning.ts +127 -0
  24. package/src/core/context-compact.ts +50 -0
  25. package/src/core/context-drain.ts +54 -0
  26. package/src/core/context-microcompact.ts +132 -0
  27. package/src/core/context-snip.ts +74 -0
  28. package/src/core/context-token.ts +108 -0
  29. package/src/core/context.ts +169 -0
  30. package/src/core/engine.ts +207 -58
  31. package/src/core/hooks-config.ts +52 -0
  32. package/src/core/hooks-executor.ts +113 -0
  33. package/src/core/hooks.ts +90 -30
  34. package/src/core/instructions.ts +12 -1
  35. package/src/core/memory/memory-loader.ts +23 -0
  36. package/src/core/memory/memory-manager.ts +192 -0
  37. package/src/core/memory/memory-writer.ts +72 -0
  38. package/src/core/permission-config.ts +34 -0
  39. package/src/core/permission-rules.ts +66 -0
  40. package/src/core/permission.ts +224 -34
  41. package/src/index.tsx +30 -2
  42. package/src/mcp/transport.ts +42 -5
  43. package/src/plugin/plugin-loader.ts +36 -0
  44. package/src/plugin/plugin-manager.ts +125 -0
  45. package/src/plugin/plugin-validator.ts +41 -0
  46. package/src/providers/fetch-utils.ts +1 -3
  47. package/src/providers/openai-compat.ts +2 -11
  48. package/src/shared/constants.ts +8 -1
  49. package/src/shared/types.ts +82 -2
  50. package/src/skills/fork-executor.ts +40 -0
  51. package/src/skills/loader.ts +48 -2
  52. package/src/tools/agent/agent.ts +20 -11
  53. package/src/tools/agent/skill.ts +32 -2
  54. package/src/tools/artifact/artifact.ts +144 -0
  55. package/src/tools/computer/app-launcher.ts +39 -0
  56. package/src/tools/computer/browser.ts +48 -0
  57. package/src/tools/computer/computer-use.ts +88 -0
  58. package/src/tools/computer/playwright.d.ts +7 -0
  59. package/src/tools/computer/screenshot.ts +57 -0
  60. package/src/tools/index.ts +6 -0
  61. package/src/ui/app.tsx +20 -7
  62. package/src/ui/chat.tsx +9 -5
  63. package/src/ui/commands.ts +185 -40
  64. package/src/ui/input.tsx +203 -27
  65. package/src/ui/picker.tsx +2 -5
  66. package/src/ui/vim-motions.ts +96 -0
  67. package/src/workflow/budget.ts +33 -0
  68. package/src/workflow/journal.ts +105 -0
  69. package/src/workflow/primitives/agent.ts +51 -0
  70. package/src/workflow/primitives/parallel.ts +8 -0
  71. package/src/workflow/primitives/phase.ts +19 -0
  72. package/src/workflow/primitives/pipeline.ts +24 -0
  73. package/src/workflow/runtime.ts +87 -0
  74. package/src/workflow/sandbox.ts +75 -0
@@ -0,0 +1,134 @@
1
+ import { readFileSync, existsSync, readdirSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { join, extname } from 'node:path'
4
+ import { parse as parseYaml } from 'yaml'
5
+ import type { AgentDefinition, SubAgentType } from './types'
6
+
7
+ interface FrontmatterResult {
8
+ data: Record<string, unknown>
9
+ content: string
10
+ }
11
+
12
+ function parseFrontmatter(raw: string): FrontmatterResult {
13
+ const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/)
14
+ if (!match) {
15
+ return { data: {}, content: raw }
16
+ }
17
+ return {
18
+ data: parseYaml(match[1] || '') as Record<string, unknown>,
19
+ content: (match[2] || '').trim(),
20
+ }
21
+ }
22
+
23
+ const BUILTIN_SYSTEM_PROMPTS: Record<SubAgentType, string> = {
24
+ general: 'You are a focused sub-agent. Complete the assigned task thoroughly and return results.',
25
+ explore:
26
+ 'You are an exploration sub-agent. Search, read, and analyze code. Return structured findings with file paths and line numbers.',
27
+ plan: 'You are a planning sub-agent. Design implementation approaches. Return a step-by-step plan with files to modify.',
28
+ 'code-review':
29
+ 'You are a code review sub-agent. Find bugs, security issues, and code quality problems. Return findings by severity.',
30
+ }
31
+
32
+ const BUILTIN_DESCRIPTIONS: Record<SubAgentType, string> = {
33
+ general: 'General-purpose agent for complex multi-step tasks.',
34
+ explore: 'Read-only search agent for broad fan-out searches across files.',
35
+ plan: 'Software architect agent for designing implementation plans.',
36
+ 'code-review': 'Code review agent for finding bugs and quality issues.',
37
+ }
38
+
39
+ export class AgentRegistry {
40
+ private agents = new Map<string, AgentDefinition>()
41
+
42
+ /** Load agents from a directory. Later loads override earlier ones for same name. */
43
+ loadDirectory(dir: string, source: 'project' | 'user'): void {
44
+ if (!existsSync(dir)) return
45
+
46
+ let entries: string[] = []
47
+ try {
48
+ entries = readdirSync(dir)
49
+ } catch {
50
+ // Permission errors, missing dir, etc.
51
+ return
52
+ }
53
+
54
+ for (const entry of entries) {
55
+ if (extname(entry) !== '.md') continue
56
+ const fullPath = join(dir, entry)
57
+ try {
58
+ const raw = readFileSync(fullPath, 'utf-8')
59
+ const { data, content } = parseFrontmatter(raw)
60
+
61
+ const name = (data.name as string) || entry.replace(/\.md$/, '')
62
+ const def: AgentDefinition = {
63
+ name,
64
+ description: (data.description as string) || '',
65
+ systemPrompt: content,
66
+ tools: data.tools as string | undefined,
67
+ disallowedTools: data.disallowedTools as string | undefined,
68
+ model: (data.model as string) || 'inherit',
69
+ permissionMode: (data.permissionMode as string) || 'inherit',
70
+ maxTurns: data.maxTurns as number | undefined,
71
+ skills: data.skills
72
+ ? String(data.skills)
73
+ .split(',')
74
+ .map((s) => s.trim())
75
+ : undefined,
76
+ background: (data.background as boolean) || false,
77
+ source,
78
+ filePath: fullPath,
79
+ }
80
+
81
+ this.agents.set(name, def)
82
+ } catch {
83
+ // Skip unparseable files
84
+ }
85
+ }
86
+ }
87
+
88
+ /** Load project-level agents from .mipham/agents/ */
89
+ loadProjectAgents(cwd: string): void {
90
+ this.loadDirectory(join(cwd, '.mipham', 'agents'), 'project')
91
+ }
92
+
93
+ /** Load user-level agents from ~/.mipham/agents/ */
94
+ loadUserAgents(): void {
95
+ const home = homedir()
96
+ this.loadDirectory(join(home, '.mipham', 'agents'), 'user')
97
+ }
98
+
99
+ /** Get a custom agent by name. Returns undefined for builtins. */
100
+ get(name: string): AgentDefinition | undefined {
101
+ return this.agents.get(name)
102
+ }
103
+
104
+ /** List all custom agents. */
105
+ list(): AgentDefinition[] {
106
+ return Array.from(this.agents.values())
107
+ }
108
+
109
+ /**
110
+ * Resolve an agent name to its definition.
111
+ * Priority: custom (project) > custom (user) > builtin.
112
+ * Builtins are always available and never return undefined.
113
+ */
114
+ resolve(name: string): AgentDefinition | undefined {
115
+ const custom = this.agents.get(name)
116
+ if (custom) return custom
117
+
118
+ // Check if it's a builtin type
119
+ const builtinType = name as SubAgentType
120
+ if (BUILTIN_SYSTEM_PROMPTS[builtinType]) {
121
+ return {
122
+ name: builtinType,
123
+ description: BUILTIN_DESCRIPTIONS[builtinType],
124
+ systemPrompt: BUILTIN_SYSTEM_PROMPTS[builtinType],
125
+ model: 'inherit',
126
+ permissionMode: 'inherit',
127
+ background: false,
128
+ source: 'builtin',
129
+ }
130
+ }
131
+
132
+ return undefined
133
+ }
134
+ }
@@ -1,14 +1,7 @@
1
- import { ContextManager } from '../core/context'
2
- import type { ProviderRegistry } from '../providers/registry'
3
- import type { Message } from '../shared/types'
4
-
5
- export type SubAgentType = 'general' | 'explore' | 'plan' | 'code-review'
6
-
7
- interface SubAgentOptions {
8
- type?: SubAgentType
9
- systemPrompt?: string
10
- maxContextMessages?: number
11
- }
1
+ import type { ProviderRegistry, ProviderInstance } from '../providers/registry'
2
+ import type { ToolDefinition } from '../shared/index.ts'
3
+ import type { SubAgentType, SubAgentOptions, AgentDefinition } from './types'
4
+ import { createAgentContext } from './agent-context'
12
5
 
13
6
  const TYPE_SYSTEM_PROMPTS: Record<SubAgentType, string> = {
14
7
  general: 'You are a focused sub-agent. Complete the assigned task thoroughly and return results.',
@@ -21,102 +14,93 @@ const TYPE_SYSTEM_PROMPTS: Record<SubAgentType, string> = {
21
14
 
22
15
  /**
23
16
  * Sub-agent engine — creates an isolated conversation context and processes
24
- * a single prompt independently. Returns the consolidated result.
25
- *
26
- * Phase 7: Pipeline-ready. Without a real AI provider, returns a structured
27
- * analysis of the prompt. Full AI integration in M3.
17
+ * a single prompt independently via the active AI provider. Returns the
18
+ * consolidated result text.
28
19
  */
29
20
  export class SubAgent {
30
- constructor(private registry: ProviderRegistry) {}
21
+ constructor(
22
+ private registry: ProviderRegistry,
23
+ private toolRegistry: Map<string, ToolDefinition>,
24
+ ) {}
31
25
 
32
26
  async execute(
33
27
  prompt: string,
34
28
  description: string,
35
29
  options: SubAgentOptions = {},
36
30
  ): Promise<string> {
31
+ const provider = this.registry.getActive()
32
+ if (!provider) {
33
+ throw new Error('No active provider available for sub-agent execution')
34
+ }
35
+
36
+ const model = this.registry.getActiveModel()
37
37
  const type = options.type || 'general'
38
- const systemPrompt = options.systemPrompt || TYPE_SYSTEM_PROMPTS[type]
38
+ const agentDef = options.agentDef
39
+
40
+ // Resolve system prompt: agentDef > options.systemPrompt > builtin type
41
+ const systemPrompt = agentDef?.systemPrompt || options.systemPrompt || TYPE_SYSTEM_PROMPTS[type]
42
+
43
+ // Create isolated context with tool scoping
44
+ const resolvedDef: AgentDefinition = agentDef || {
45
+ name: type,
46
+ description: '',
47
+ systemPrompt,
48
+ model: options.modelOverride || 'inherit',
49
+ permissionMode: 'inherit',
50
+ background: false,
51
+ source: 'builtin',
52
+ }
53
+ const { context, allowedTools } = createAgentContext(
54
+ resolvedDef,
55
+ this.toolRegistry,
56
+ options.maxContextMessages,
57
+ )
39
58
 
40
- // Create isolated context for this sub-agent
41
- const context = new ContextManager({
42
- maxTokens: 100_000,
43
- compactionThreshold: 0.9,
44
- })
45
59
  context.setSystemPrompt(systemPrompt)
46
60
  context.addMessage({ role: 'user', content: prompt })
47
61
 
48
- // Phase 7 pipeline: attempt real AI, fall back to structured simulation
49
- try {
50
- const provider = this.registry.getActive()
51
- if (provider) {
52
- const messages = context.getMessages()
53
- const chunks: string[] = []
62
+ const messages = context.getMessages()
63
+ const toolDefs =
64
+ allowedTools.length > 0
65
+ ? allowedTools.map((t) => ({
66
+ name: t.name,
67
+ description: t.description,
68
+ parameters: t.parameters,
69
+ input_schema: t.parameters,
70
+ }))
71
+ : undefined
54
72
 
55
- for await (const chunk of provider.chat({
56
- model: this.registry.getActiveModel(),
57
- messages,
58
- systemPrompt,
59
- maxTokens: 4096,
60
- })) {
61
- if (chunk.type === 'text' && chunk.content) {
62
- chunks.push(chunk.content)
63
- }
64
- if (chunk.type === 'error') {
65
- // Fall through to simulation on API error
66
- throw new Error(chunk.error)
67
- }
68
- }
73
+ const modelToUse = options.modelOverride || agentDef?.model || model
74
+ // 'inherit' means use parent model
75
+ const resolvedModel = modelToUse === 'inherit' ? model : modelToUse
69
76
 
70
- if (chunks.length > 0) {
71
- return chunks.join('')
77
+ const chunks: string[] = []
78
+
79
+ try {
80
+ for await (const chunk of provider.chat({
81
+ model: resolvedModel,
82
+ messages,
83
+ systemPrompt,
84
+ tools: toolDefs,
85
+ maxTokens: 4096,
86
+ })) {
87
+ if (chunk.type === 'text' && chunk.content) {
88
+ chunks.push(chunk.content)
89
+ }
90
+ if (chunk.type === 'error') {
91
+ throw new Error(`Sub-agent execution failed: ${chunk.error}`)
92
+ }
93
+ if (chunk.type === 'stop') {
94
+ break
72
95
  }
73
96
  }
74
- } catch {
75
- // API unavailable use simulation mode
76
- }
77
-
78
- // Simulation mode: return structured task analysis
79
- return this.simulate(prompt, description, type)
80
- }
81
-
82
- private simulate(prompt: string, description: string, type: SubAgentType): string {
83
- const lines: string[] = [
84
- `── Sub-Agent Result (${type}) ──`,
85
- '',
86
- `Task: ${description}`,
87
- `Prompt: ${prompt.slice(0, 200)}${prompt.length > 200 ? '...' : ''}`,
88
- '',
89
- ]
90
-
91
- switch (type) {
92
- case 'explore':
93
- lines.push(
94
- 'Analysis: This exploration task would search the codebase for relevant',
95
- 'files, patterns, and structures. The sub-agent pipeline is ready — connect',
96
- 'a provider API key for real AI-powered exploration.',
97
- )
98
- break
99
- case 'plan':
100
- lines.push(
101
- 'Plan: A step-by-step implementation plan would be generated based on',
102
- 'the requirements. Use /plan mode for interactive planning.',
103
- )
104
- break
105
- case 'code-review':
106
- lines.push(
107
- 'Review: The code review sub-agent would analyze the specified files for',
108
- 'bugs, security issues, and code quality concerns. Use /review for',
109
- 'interactive code review.',
110
- )
111
- break
112
- default:
113
- lines.push(
114
- 'The sub-agent pipeline is operational. Connect an API key to enable',
115
- 'AI-powered sub-agent execution. The infrastructure for context isolation,',
116
- 'prompt routing, and result aggregation is in place.',
117
- )
97
+ } catch (err) {
98
+ if (err instanceof Error && err.message.startsWith('Sub-agent')) {
99
+ throw err
100
+ }
101
+ throw new Error(`Sub-agent execution failed: ${String(err)}`)
118
102
  }
119
103
 
120
- return lines.join('\n')
104
+ return chunks.join('')
121
105
  }
122
106
  }
@@ -0,0 +1,39 @@
1
+ // apps/cli/src/agent/types.ts
2
+
3
+ export type SubAgentType = 'general' | 'explore' | 'plan' | 'code-review'
4
+
5
+ export interface AgentFrontmatter {
6
+ name: string
7
+ description: string
8
+ tools?: string // comma-separated allowlist
9
+ disallowedTools?: string
10
+ model?: string // 'sonnet' | 'opus' | 'haiku' | 'inherit' | full model ID
11
+ permissionMode?: 'default' | 'acceptEdits' | 'auto' | 'bypass' | 'plan'
12
+ maxTurns?: number
13
+ skills?: string
14
+ background?: boolean
15
+ }
16
+
17
+ export interface AgentDefinition {
18
+ name: string
19
+ description: string
20
+ systemPrompt: string // markdown body after frontmatter
21
+ tools?: string
22
+ disallowedTools?: string
23
+ model: string
24
+ permissionMode: string
25
+ maxTurns?: number
26
+ skills?: string[]
27
+ background: boolean
28
+ source: 'builtin' | 'project' | 'user'
29
+ filePath?: string
30
+ }
31
+
32
+ export interface SubAgentOptions {
33
+ type?: SubAgentType
34
+ agentDef?: AgentDefinition
35
+ systemPrompt?: string
36
+ maxContextMessages?: number
37
+ allowedTools?: string[]
38
+ modelOverride?: string
39
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * AgentViewManager — multi-session lifecycle manager for background agent sessions.
3
+ *
4
+ * Each session represents a background sub-agent task. The manager tracks
5
+ * status transitions, elapsed time, and provides grouping/peek/attach/kill
6
+ * operations used by the Agent View Dashboard TUI.
7
+ */
8
+
9
+ export type SessionStatus = 'needs-input' | 'working' | 'completed' | 'failed'
10
+
11
+ export interface SessionMessage {
12
+ role: 'user' | 'assistant' | 'system'
13
+ content: string
14
+ }
15
+
16
+ export interface AgentSession {
17
+ id: string
18
+ title: string
19
+ status: SessionStatus
20
+ provider: string
21
+ model: string
22
+ task: string
23
+ createdAt: Date
24
+ startedAt?: Date
25
+ completedAt?: Date
26
+ elapsedMs: number
27
+ messages: SessionMessage[]
28
+ }
29
+
30
+ export interface CreateSessionOptions {
31
+ provider?: string
32
+ model?: string
33
+ }
34
+
35
+ export interface SessionPeek {
36
+ session: AgentSession
37
+ recentMessages: SessionMessage[]
38
+ }
39
+
40
+ export type StatusGroups = Record<SessionStatus, AgentSession[]>
41
+
42
+ export class AgentViewManager {
43
+ private sessions: Map<string, AgentSession> = new Map()
44
+ private sessionOrder: string[] = []
45
+ private idCounter = 0
46
+
47
+ /**
48
+ * Create a new background agent session.
49
+ */
50
+ create(title: string, task: string, options: CreateSessionOptions = {}): AgentSession {
51
+ const id = `agent-${++this.idCounter}-${Date.now()}`
52
+ const session: AgentSession = {
53
+ id,
54
+ title,
55
+ status: 'needs-input',
56
+ provider: options.provider ?? 'unknown',
57
+ model: options.model ?? 'unknown',
58
+ task,
59
+ createdAt: new Date(),
60
+ elapsedMs: 0,
61
+ messages: [],
62
+ }
63
+
64
+ this.sessions.set(id, session)
65
+ this.sessionOrder.push(id)
66
+
67
+ return session
68
+ }
69
+
70
+ /**
71
+ * List all sessions in creation order (newest first).
72
+ */
73
+ list(): AgentSession[] {
74
+ return [...this.sessionOrder]
75
+ .reverse()
76
+ .map((id) => this.sessions.get(id)!)
77
+ .filter(Boolean)
78
+ }
79
+
80
+ /**
81
+ * Group all sessions by their current status.
82
+ * Returns a record with keys for all four statuses (empty arrays if none).
83
+ */
84
+ groupByStatus(): StatusGroups {
85
+ const groups: StatusGroups = {
86
+ 'needs-input': [],
87
+ working: [],
88
+ completed: [],
89
+ failed: [],
90
+ }
91
+
92
+ for (const id of this.sessionOrder) {
93
+ const session = this.sessions.get(id)
94
+ if (session) {
95
+ groups[session.status].push(session)
96
+ }
97
+ }
98
+
99
+ return groups
100
+ }
101
+
102
+ /**
103
+ * Get a session by ID.
104
+ */
105
+ get(id: string): AgentSession | undefined {
106
+ return this.sessions.get(id)
107
+ }
108
+
109
+ /**
110
+ * Peek at a session — returns the session metadata and up to 5 recent messages.
111
+ */
112
+ peek(id: string): SessionPeek | undefined {
113
+ const session = this.sessions.get(id)
114
+ if (!session) return undefined
115
+
116
+ const recentMessages = session.messages.slice(-5)
117
+ return { session, recentMessages }
118
+ }
119
+
120
+ /**
121
+ * Attach to a session — marks the session as working (if needs-input) and
122
+ * returns it so the caller can switch the main UI to this session.
123
+ */
124
+ attach(id: string): AgentSession | undefined {
125
+ const session = this.sessions.get(id)
126
+ if (!session) return undefined
127
+
128
+ // If the session was waiting for input, transition to working
129
+ if (session.status === 'needs-input') {
130
+ session.status = 'working'
131
+ session.startedAt = new Date()
132
+ }
133
+
134
+ return session
135
+ }
136
+
137
+ /**
138
+ * Kill (terminate) a session. Only kills sessions that are not already completed/failed.
139
+ * Returns true if the session was found and killed, false otherwise.
140
+ */
141
+ kill(id: string): boolean {
142
+ const session = this.sessions.get(id)
143
+ if (!session) return false
144
+
145
+ // Don't re-kill already terminal sessions
146
+ if (session.status === 'completed' || session.status === 'failed') {
147
+ return false
148
+ }
149
+
150
+ session.status = 'failed'
151
+ session.completedAt = new Date()
152
+ return true
153
+ }
154
+
155
+ /**
156
+ * Update a session's status and optionally add a message.
157
+ */
158
+ updateStatus(id: string, status: SessionStatus): boolean {
159
+ const session = this.sessions.get(id)
160
+ if (!session) return false
161
+
162
+ session.status = status
163
+
164
+ if (status === 'working' && !session.startedAt) {
165
+ session.startedAt = new Date()
166
+ }
167
+ if ((status === 'completed' || status === 'failed') && !session.completedAt) {
168
+ session.completedAt = new Date()
169
+ }
170
+
171
+ return true
172
+ }
173
+
174
+ /**
175
+ * Add a message to a session's message history.
176
+ */
177
+ addMessage(id: string, message: SessionMessage): boolean {
178
+ const session = this.sessions.get(id)
179
+ if (!session) return false
180
+
181
+ session.messages.push(message)
182
+ return true
183
+ }
184
+
185
+ /**
186
+ * Count sessions by status.
187
+ */
188
+ countByStatus(): Record<SessionStatus, number> {
189
+ const counts: Record<SessionStatus, number> = {
190
+ 'needs-input': 0,
191
+ working: 0,
192
+ completed: 0,
193
+ failed: 0,
194
+ }
195
+
196
+ for (const [, session] of this.sessions) {
197
+ counts[session.status]++
198
+ }
199
+
200
+ return counts
201
+ }
202
+
203
+ /**
204
+ * Remove all completed and failed sessions (cleanup).
205
+ */
206
+ prune(): number {
207
+ let removed = 0
208
+ for (const [id, session] of this.sessions) {
209
+ if (session.status === 'completed' || session.status === 'failed') {
210
+ this.sessions.delete(id)
211
+ this.sessionOrder = this.sessionOrder.filter((oid) => oid !== id)
212
+ removed++
213
+ }
214
+ }
215
+ return removed
216
+ }
217
+ }