@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.
- package/bin/mipham.ts +188 -0
- package/dist/mipham +0 -0
- package/package.json +9 -9
- package/skills/mipham/om-artifact.mipham-skill.md +69 -0
- package/skills/mipham/om-model-optimize.mipham-skill.md +9 -6
- package/skills/mipham/om-security.mipham-skill.md +11 -8
- package/skills/standard/doc-generator.SKILL.md +6 -1
- package/skills/standard/github-ops.SKILL.md +12 -7
- package/skills/standard/memory.SKILL.md +1 -0
- package/skills/standard/self-review.SKILL.md +1 -0
- package/skills/standard/superpower.SKILL.md +7 -7
- package/skills/standard/web-search.SKILL.md +3 -0
- package/src/agent/agent-context.ts +56 -0
- package/src/agent/agent-registry.ts +134 -0
- package/src/agent/sub-agent.ts +73 -89
- package/src/agent/types.ts +39 -0
- package/src/agent-view/agent-view-manager.ts +217 -0
- package/src/agent-view/dashboard.tsx +218 -0
- package/src/agent-view/session-peek.tsx +72 -0
- package/src/agent-view/session-row.tsx +70 -0
- package/src/artifacts/manifest.ts +101 -0
- package/src/artifacts/server.ts +374 -0
- package/src/artifacts/versioning.ts +127 -0
- package/src/core/context-compact.ts +50 -0
- package/src/core/context-drain.ts +54 -0
- package/src/core/context-microcompact.ts +132 -0
- package/src/core/context-snip.ts +74 -0
- package/src/core/context-token.ts +108 -0
- package/src/core/context.ts +169 -0
- package/src/core/engine.ts +207 -58
- package/src/core/hooks-config.ts +52 -0
- package/src/core/hooks-executor.ts +113 -0
- package/src/core/hooks.ts +90 -30
- package/src/core/instructions.ts +12 -1
- package/src/core/memory/memory-loader.ts +23 -0
- package/src/core/memory/memory-manager.ts +192 -0
- package/src/core/memory/memory-writer.ts +72 -0
- package/src/core/permission-config.ts +34 -0
- package/src/core/permission-rules.ts +66 -0
- package/src/core/permission.ts +224 -34
- package/src/index.tsx +30 -2
- package/src/mcp/transport.ts +42 -5
- package/src/plugin/plugin-loader.ts +36 -0
- package/src/plugin/plugin-manager.ts +125 -0
- package/src/plugin/plugin-validator.ts +41 -0
- package/src/providers/fetch-utils.ts +1 -3
- package/src/providers/openai-compat.ts +2 -11
- package/src/shared/constants.ts +8 -1
- package/src/shared/types.ts +82 -2
- package/src/skills/fork-executor.ts +40 -0
- package/src/skills/loader.ts +48 -2
- package/src/tools/agent/agent.ts +20 -11
- package/src/tools/agent/skill.ts +32 -2
- package/src/tools/artifact/artifact.ts +144 -0
- package/src/tools/computer/app-launcher.ts +39 -0
- package/src/tools/computer/browser.ts +48 -0
- package/src/tools/computer/computer-use.ts +88 -0
- package/src/tools/computer/playwright.d.ts +7 -0
- package/src/tools/computer/screenshot.ts +57 -0
- package/src/tools/index.ts +6 -0
- package/src/ui/app.tsx +20 -7
- package/src/ui/chat.tsx +9 -5
- package/src/ui/commands.ts +185 -40
- package/src/ui/input.tsx +203 -27
- package/src/ui/picker.tsx +2 -5
- package/src/ui/vim-motions.ts +96 -0
- package/src/workflow/budget.ts +33 -0
- package/src/workflow/journal.ts +105 -0
- package/src/workflow/primitives/agent.ts +51 -0
- package/src/workflow/primitives/parallel.ts +8 -0
- package/src/workflow/primitives/phase.ts +19 -0
- package/src/workflow/primitives/pipeline.ts +24 -0
- package/src/workflow/runtime.ts +87 -0
- package/src/workflow/sandbox.ts +75 -0
package/src/shared/types.ts
CHANGED
|
@@ -50,7 +50,7 @@ export interface Message {
|
|
|
50
50
|
|
|
51
51
|
// ── Tool Types ──
|
|
52
52
|
export type ToolPermission = 'auto' | 'ask' | 'bypass'
|
|
53
|
-
export type ToolCategory = 'file' | 'exec' | 'agent' | 'network' | 'system'
|
|
53
|
+
export type ToolCategory = 'file' | 'exec' | 'agent' | 'network' | 'system' | 'artifact'
|
|
54
54
|
|
|
55
55
|
export interface ToolContext {
|
|
56
56
|
cwd: string
|
|
@@ -59,6 +59,28 @@ export interface ToolContext {
|
|
|
59
59
|
model: string
|
|
60
60
|
skillsLoader?: import('../skills/loader').SkillsLoader
|
|
61
61
|
registry?: import('../providers/registry').ProviderRegistry
|
|
62
|
+
toolRegistry?: Map<string, ToolDefinition>
|
|
63
|
+
artifactServer?: import('../artifacts/server').ArtifactServer
|
|
64
|
+
agentRegistry?: import('../agent/agent-registry').AgentRegistry
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Artifact Types ──
|
|
68
|
+
export interface ArtifactEntry {
|
|
69
|
+
name: string
|
|
70
|
+
path: string
|
|
71
|
+
url: string
|
|
72
|
+
size: number
|
|
73
|
+
type: 'html' | 'svg'
|
|
74
|
+
createdAt: string
|
|
75
|
+
sessionId: string
|
|
76
|
+
versions?: string[] // version tags e.g. ['v1', 'v2']
|
|
77
|
+
versionCount?: number
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ArtifactManifest {
|
|
81
|
+
version: 1
|
|
82
|
+
artifacts: ArtifactEntry[]
|
|
83
|
+
port?: number
|
|
62
84
|
}
|
|
63
85
|
|
|
64
86
|
export interface ToolResult {
|
|
@@ -111,6 +133,16 @@ export interface SkillDefinition {
|
|
|
111
133
|
tools?: ToolDefinition[]
|
|
112
134
|
hooks?: HookDefinition[]
|
|
113
135
|
prompts?: Record<string, string>
|
|
136
|
+
/** Frontmatter: 'fork' means execute in isolated subagent, undefined means inline */
|
|
137
|
+
context?: string
|
|
138
|
+
/** Model override for fork execution */
|
|
139
|
+
model?: string
|
|
140
|
+
/** Tool whitelist for fork mode */
|
|
141
|
+
allowedTools?: string[]
|
|
142
|
+
/** When true, the skill is NOT shown in system-reminder for AI auto-triggering */
|
|
143
|
+
disableModelInvocation?: boolean
|
|
144
|
+
/** When true, users can invoke this skill directly via /<name> */
|
|
145
|
+
userInvocable?: boolean
|
|
114
146
|
}
|
|
115
147
|
|
|
116
148
|
// ── Hook Types ──
|
|
@@ -120,6 +152,25 @@ export type HookEvent =
|
|
|
120
152
|
| 'SessionStart'
|
|
121
153
|
| 'SessionEnd'
|
|
122
154
|
| 'Notification'
|
|
155
|
+
| 'Stop'
|
|
156
|
+
| 'UserPromptSubmit'
|
|
157
|
+
| 'PreCompact'
|
|
158
|
+
| 'PostCompact'
|
|
159
|
+
| 'ConfigChange'
|
|
160
|
+
|
|
161
|
+
export type HookType = 'command' | 'http' | 'code' | 'mcp_tool'
|
|
162
|
+
|
|
163
|
+
export interface HookConfig {
|
|
164
|
+
type: HookType
|
|
165
|
+
command?: string
|
|
166
|
+
args?: string[]
|
|
167
|
+
url?: string
|
|
168
|
+
method?: 'GET' | 'POST'
|
|
169
|
+
headers?: Record<string, string>
|
|
170
|
+
mcpServer?: string
|
|
171
|
+
mcpTool?: string
|
|
172
|
+
continueOnBlock?: boolean
|
|
173
|
+
}
|
|
123
174
|
|
|
124
175
|
export interface HookDefinition {
|
|
125
176
|
event: HookEvent
|
|
@@ -133,12 +184,19 @@ export interface HookContext {
|
|
|
133
184
|
toolInput?: Record<string, unknown>
|
|
134
185
|
toolResult?: ToolResult
|
|
135
186
|
sessionId: string
|
|
187
|
+
userPrompt?: string
|
|
188
|
+
configKey?: string
|
|
189
|
+
configValue?: unknown
|
|
136
190
|
}
|
|
137
191
|
|
|
138
192
|
export interface HookResult {
|
|
139
193
|
allowed: boolean
|
|
140
194
|
reason?: string
|
|
141
195
|
modifiedInput?: Record<string, unknown>
|
|
196
|
+
decision?: 'allow' | 'block'
|
|
197
|
+
permissionDecision?: 'allow' | 'deny' | 'ask' | 'defer'
|
|
198
|
+
additionalContext?: string
|
|
199
|
+
updatedOutput?: string
|
|
142
200
|
}
|
|
143
201
|
|
|
144
202
|
// ── Instruction Types ──
|
|
@@ -152,7 +210,29 @@ export interface InstructionFile {
|
|
|
152
210
|
}
|
|
153
211
|
|
|
154
212
|
// ── Permission Types ──
|
|
155
|
-
|
|
213
|
+
/** Six explicit permission modes matching Claude Code's permission architecture */
|
|
214
|
+
export type PermissionMode =
|
|
215
|
+
| 'default'
|
|
216
|
+
| 'acceptEdits'
|
|
217
|
+
| 'plan'
|
|
218
|
+
| 'auto'
|
|
219
|
+
| 'dontAsk'
|
|
220
|
+
| 'bypassPermissions'
|
|
221
|
+
|
|
222
|
+
/** Backward-compatible alias: PermissionMode plus legacy 'ask' and 'bypass' */
|
|
223
|
+
export type PermissionLevel = PermissionMode | 'ask' | 'bypass'
|
|
224
|
+
|
|
225
|
+
export interface PermissionConfig {
|
|
226
|
+
mode: PermissionMode
|
|
227
|
+
allow: string[]
|
|
228
|
+
deny: string[]
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export interface PermissionRuleEntry {
|
|
232
|
+
pattern: string // e.g., "Bash(git:*)"
|
|
233
|
+
level: 'allow' | 'deny' | 'ask'
|
|
234
|
+
compiled: RegExp
|
|
235
|
+
}
|
|
156
236
|
|
|
157
237
|
export interface PermissionRule {
|
|
158
238
|
toolName: string
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { SubAgent } from '../agent/sub-agent'
|
|
2
|
+
import type { ProviderRegistry } from '../providers/registry'
|
|
3
|
+
import type { ToolDefinition, SkillDefinition } from '../shared/index.ts'
|
|
4
|
+
import type { AgentDefinition } from '../agent/types'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Execute a skill in an isolated subagent context (context: fork).
|
|
8
|
+
*
|
|
9
|
+
* The skill's markdown body becomes the subagent's system prompt.
|
|
10
|
+
* The skill's allowed-tools become the subagent's tool whitelist.
|
|
11
|
+
* Results are returned to the AI as internal context (not shown directly to user).
|
|
12
|
+
*/
|
|
13
|
+
export async function executeForkedSkill(
|
|
14
|
+
skill: SkillDefinition,
|
|
15
|
+
args: string,
|
|
16
|
+
registry: ProviderRegistry,
|
|
17
|
+
toolRegistry: Map<string, ToolDefinition>,
|
|
18
|
+
): Promise<string> {
|
|
19
|
+
const agentDef: AgentDefinition = {
|
|
20
|
+
name: `skill:${skill.name}`,
|
|
21
|
+
description: skill.description,
|
|
22
|
+
systemPrompt: buildSkillSystemPrompt(skill),
|
|
23
|
+
tools: skill.allowedTools?.join(', '),
|
|
24
|
+
model: skill.model || 'inherit',
|
|
25
|
+
permissionMode: 'inherit',
|
|
26
|
+
background: false,
|
|
27
|
+
source: 'builtin',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const sub = new SubAgent(registry, toolRegistry)
|
|
31
|
+
const prompt = args
|
|
32
|
+
? `Execute the "${skill.name}" skill with arguments: ${args}`
|
|
33
|
+
: `Execute the "${skill.name}" skill.`
|
|
34
|
+
|
|
35
|
+
return sub.execute(prompt, `skill:${skill.name}`, { agentDef })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function buildSkillSystemPrompt(skill: SkillDefinition): string {
|
|
39
|
+
return `You are executing the "${skill.name}" skill (v${skill.version}).\n\n${skill.description}\n\nFollow the skill instructions precisely and return results.`
|
|
40
|
+
}
|
package/src/skills/loader.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'
|
|
2
|
-
import { join
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
3
4
|
import { parse as parseYaml } from 'yaml'
|
|
4
5
|
import type { SkillDefinition } from '../shared/index.ts'
|
|
5
6
|
|
|
@@ -84,7 +85,7 @@ export class SkillsLoader {
|
|
|
84
85
|
private tryLoad(path: string, type: 'standard' | 'mipham'): void {
|
|
85
86
|
try {
|
|
86
87
|
const raw = readFileSync(path, 'utf-8')
|
|
87
|
-
const { data
|
|
88
|
+
const { data } = parseFrontmatter(raw)
|
|
88
89
|
|
|
89
90
|
const skill: SkillDefinition = {
|
|
90
91
|
name: (data.name as string) || this.nameFromPath(path),
|
|
@@ -94,6 +95,12 @@ export class SkillsLoader {
|
|
|
94
95
|
tools: data.tools as SkillDefinition['tools'],
|
|
95
96
|
hooks: data.hooks as SkillDefinition['hooks'],
|
|
96
97
|
prompts: data.prompts as SkillDefinition['prompts'],
|
|
98
|
+
// NEW: frontmatter fields for fork/auto-trigger support
|
|
99
|
+
context: data.context as string | undefined,
|
|
100
|
+
model: data.model as string | undefined,
|
|
101
|
+
allowedTools: data['allowed-tools'] as string[] | undefined,
|
|
102
|
+
disableModelInvocation: data['disable-model-invocation'] as boolean | undefined,
|
|
103
|
+
userInvocable: data['user-invocable'] as boolean | undefined,
|
|
97
104
|
}
|
|
98
105
|
|
|
99
106
|
this.skills.set(skill.name, skill)
|
|
@@ -102,6 +109,45 @@ export class SkillsLoader {
|
|
|
102
109
|
}
|
|
103
110
|
}
|
|
104
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Build the system-reminder block for AI auto-triggering.
|
|
114
|
+
* Injects available skill names + descriptions so the AI can match
|
|
115
|
+
* user requests to relevant skills.
|
|
116
|
+
*/
|
|
117
|
+
buildSystemReminder(maxTokens: number = 5000): string {
|
|
118
|
+
const skills = this.list().filter((s) => {
|
|
119
|
+
// Skip skills that disable model invocation
|
|
120
|
+
return !s.disableModelInvocation
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
if (skills.length === 0) return ''
|
|
124
|
+
|
|
125
|
+
const lines: string[] = [
|
|
126
|
+
'<system-reminder>',
|
|
127
|
+
'The following skills are available. Invoke via the Skill tool when relevant:',
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
let tokenBudget = 0
|
|
131
|
+
for (const skill of skills) {
|
|
132
|
+
const entry = `- ${skill.name}: ${skill.description}`
|
|
133
|
+
const entryTokens = Math.ceil(entry.length / 4) + 1 // rough estimate
|
|
134
|
+
if (tokenBudget + entryTokens > maxTokens) break
|
|
135
|
+
|
|
136
|
+
lines.push(entry)
|
|
137
|
+
tokenBudget += entryTokens
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
lines.push('</system-reminder>')
|
|
141
|
+
return lines.join('\n')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Load skills from ~/.mipham/skills/ (user home directory). */
|
|
145
|
+
loadUserSkills(): void {
|
|
146
|
+
const home = homedir()
|
|
147
|
+
const userSkillsPath = join(home, '.mipham', 'skills')
|
|
148
|
+
this.loadExternal([userSkillsPath])
|
|
149
|
+
}
|
|
150
|
+
|
|
105
151
|
private nameFromPath(path: string): string {
|
|
106
152
|
const base = path.split('/').pop() || ''
|
|
107
153
|
return base.replace(/\.(SKILL|mipham-skill)\.md$/i, '')
|
package/src/tools/agent/agent.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
2
|
-
import { SubAgent
|
|
2
|
+
import { SubAgent } from '../../agent/sub-agent'
|
|
3
|
+
import type { SubAgentType } from '../../agent/types'
|
|
3
4
|
|
|
4
5
|
const VALID_TYPES: SubAgentType[] = ['general', 'explore', 'plan', 'code-review']
|
|
5
6
|
|
|
@@ -35,17 +36,25 @@ export const agentTool: ToolDefinition = {
|
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
const registry = ctx.registry
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
39
|
+
const toolRegistry = ctx.toolRegistry
|
|
40
|
+
if (!registry || !toolRegistry) {
|
|
41
|
+
return {
|
|
42
|
+
success: false,
|
|
43
|
+
content: '',
|
|
44
|
+
error:
|
|
45
|
+
'Sub-agent execution requires an active provider and tool registry. Connect a provider API key first.',
|
|
46
|
+
}
|
|
45
47
|
}
|
|
46
48
|
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
49
|
+
// Resolve agent definition from registry (custom > builtin)
|
|
50
|
+
const agentDef = ctx.agentRegistry?.resolve(agentType)
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const sub = new SubAgent(registry, toolRegistry)
|
|
54
|
+
const result = await sub.execute(prompt, description, { type: agentType, agentDef })
|
|
55
|
+
return { success: true, content: result }
|
|
56
|
+
} catch (err) {
|
|
57
|
+
return { success: false, content: '', error: String(err) }
|
|
58
|
+
}
|
|
50
59
|
},
|
|
51
60
|
}
|
package/src/tools/agent/skill.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { ToolDefinition } from '../../shared/index.ts'
|
|
1
|
+
import type { ToolDefinition, SkillDefinition, ToolResult } from '../../shared/index.ts'
|
|
2
|
+
import { executeForkedSkill } from '../../skills/fork-executor'
|
|
2
3
|
|
|
3
4
|
export const skillTool: ToolDefinition = {
|
|
4
5
|
name: 'Skill',
|
|
@@ -34,7 +35,36 @@ export const skillTool: ToolDefinition = {
|
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
//
|
|
38
|
+
// Check if skill has context: fork — execute in isolated subagent
|
|
39
|
+
if (skill.context === 'fork') {
|
|
40
|
+
const registry = ctx.registry
|
|
41
|
+
if (!registry) {
|
|
42
|
+
return {
|
|
43
|
+
success: false,
|
|
44
|
+
content: '',
|
|
45
|
+
error: 'Provider registry not available for forked skill execution.',
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const result = await executeForkedSkill(
|
|
51
|
+
skill,
|
|
52
|
+
args,
|
|
53
|
+
registry,
|
|
54
|
+
ctx.toolRegistry || new Map(),
|
|
55
|
+
)
|
|
56
|
+
// Return to AI as internal context
|
|
57
|
+
return { success: true, content: `[Forked skill "${skillName}" result]:\n${result}` }
|
|
58
|
+
} catch (err) {
|
|
59
|
+
return {
|
|
60
|
+
success: false,
|
|
61
|
+
content: '',
|
|
62
|
+
error: `Forked skill execution failed: ${String(err)}`,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Standard inline execution — return metadata for AI to follow
|
|
38
68
|
const lines: string[] = [
|
|
39
69
|
`── Skill Invoked: ${skill.name} ──`,
|
|
40
70
|
`Type: ${skill.type} | Version: ${skill.version}`,
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { writeFileSync, mkdirSync, existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import type { ToolDefinition } from '../../shared/index.ts'
|
|
4
|
+
import { ARTIFACTS_DIR, ARTIFACT_MAX_SIZE } from '../../shared/constants'
|
|
5
|
+
import { addToManifest, readManifest, archiveVersion } from '../../artifacts/manifest'
|
|
6
|
+
|
|
7
|
+
const NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/
|
|
8
|
+
|
|
9
|
+
export const artifactTool: ToolDefinition = {
|
|
10
|
+
name: 'Artifact',
|
|
11
|
+
description:
|
|
12
|
+
'Create an interactive HTML or SVG artifact that opens in the browser. Use for dashboards, visualizations, reports, and interactive demos. Content must be self-contained — no external CDN references.',
|
|
13
|
+
category: 'artifact',
|
|
14
|
+
permission: 'ask',
|
|
15
|
+
parameters: {
|
|
16
|
+
type: 'object',
|
|
17
|
+
properties: {
|
|
18
|
+
name: {
|
|
19
|
+
type: 'string',
|
|
20
|
+
description: 'Short kebab-case name (e.g. "user-dashboard", "pipeline-diagram")',
|
|
21
|
+
},
|
|
22
|
+
type: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
enum: ['html', 'svg'],
|
|
25
|
+
description: 'Artifact type',
|
|
26
|
+
},
|
|
27
|
+
content: {
|
|
28
|
+
type: 'string',
|
|
29
|
+
description: 'Full HTML or SVG content (self-contained, inline CSS/JS only)',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
required: ['name', 'type', 'content'],
|
|
33
|
+
},
|
|
34
|
+
async execute(params, ctx) {
|
|
35
|
+
const name = params.name as string
|
|
36
|
+
const type = params.type as string
|
|
37
|
+
const content = params.content as string
|
|
38
|
+
|
|
39
|
+
// Validate name
|
|
40
|
+
if (!NAME_PATTERN.test(name) || name.length < 3) {
|
|
41
|
+
return {
|
|
42
|
+
success: false,
|
|
43
|
+
content: '',
|
|
44
|
+
error:
|
|
45
|
+
`Invalid artifact name "${name}". Use kebab-case (letters, numbers, hyphens), ` +
|
|
46
|
+
`at least 3 characters, e.g. "my-dashboard".`,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Validate size
|
|
51
|
+
const size = Buffer.byteLength(content, 'utf-8')
|
|
52
|
+
if (size > ARTIFACT_MAX_SIZE) {
|
|
53
|
+
return {
|
|
54
|
+
success: false,
|
|
55
|
+
content: '',
|
|
56
|
+
error: `Content size ${(size / 1_048_576).toFixed(1)}MB exceeds ${ARTIFACT_MAX_SIZE / 1_048_576}MB limit.`,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Determine output paths
|
|
61
|
+
const baseDir = join(ctx.cwd, ARTIFACTS_DIR)
|
|
62
|
+
const sessionDir = join(baseDir, ctx.sessionId)
|
|
63
|
+
mkdirSync(sessionDir, { recursive: true })
|
|
64
|
+
|
|
65
|
+
const ext = type === 'svg' ? '.svg' : '.html'
|
|
66
|
+
const filename = `${name}${ext}`
|
|
67
|
+
const filepath = join(sessionDir, filename)
|
|
68
|
+
|
|
69
|
+
// Start server if not running
|
|
70
|
+
let port: number | undefined
|
|
71
|
+
if (ctx.artifactServer && !ctx.artifactServer.isRunning()) {
|
|
72
|
+
port = await ctx.artifactServer.start()
|
|
73
|
+
} else if (ctx.artifactServer) {
|
|
74
|
+
port = ctx.artifactServer.getPort()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Archive previous version if this artifact already exists
|
|
78
|
+
let archivedVersion: string | undefined
|
|
79
|
+
const isUpdate = existsSync(filepath)
|
|
80
|
+
if (isUpdate) {
|
|
81
|
+
const manifest = readManifest(baseDir)
|
|
82
|
+
const existing = manifest.artifacts.find(
|
|
83
|
+
(a) => a.name === name && a.sessionId === ctx.sessionId,
|
|
84
|
+
)
|
|
85
|
+
if (existing) {
|
|
86
|
+
archivedVersion = archiveVersion(baseDir, existing)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Write artifact
|
|
91
|
+
writeFileSync(filepath, content, 'utf-8')
|
|
92
|
+
|
|
93
|
+
// Build URL and manifest entry
|
|
94
|
+
const url = port
|
|
95
|
+
? `http://localhost:${port}/${ctx.sessionId}/${filename}`
|
|
96
|
+
: `file://${filepath}`
|
|
97
|
+
|
|
98
|
+
// Preserve existing version count
|
|
99
|
+
const manifestPre = readManifest(baseDir)
|
|
100
|
+
const prev = manifestPre.artifacts.find((a) => a.name === name && a.sessionId === ctx.sessionId)
|
|
101
|
+
const versionCount = prev?.versionCount || (isUpdate ? 1 : undefined)
|
|
102
|
+
|
|
103
|
+
addToManifest(
|
|
104
|
+
baseDir,
|
|
105
|
+
{
|
|
106
|
+
name,
|
|
107
|
+
path: filepath,
|
|
108
|
+
url,
|
|
109
|
+
size,
|
|
110
|
+
type: type as 'html' | 'svg',
|
|
111
|
+
createdAt: new Date().toISOString(),
|
|
112
|
+
sessionId: ctx.sessionId,
|
|
113
|
+
versions: prev?.versions,
|
|
114
|
+
versionCount,
|
|
115
|
+
},
|
|
116
|
+
port,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
// Notify SSE clients to refresh Gallery
|
|
120
|
+
if (ctx.artifactServer) {
|
|
121
|
+
ctx.artifactServer.notifyReload()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const galleryUrl = port ? `http://localhost:${port}` : undefined
|
|
125
|
+
const versionLine = archivedVersion ? ` Prev archived as: ${archivedVersion}` : ''
|
|
126
|
+
const galleryLine = galleryUrl ? `Gallery: ${galleryUrl}` : ''
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
success: true,
|
|
130
|
+
content: [
|
|
131
|
+
`✓ Artifact "${name}" saved${isUpdate ? ' (updated)' : ''}.`,
|
|
132
|
+
` URL: ${url}`,
|
|
133
|
+
` Size: ${size.toLocaleString()} bytes`,
|
|
134
|
+
versionLine,
|
|
135
|
+
galleryLine,
|
|
136
|
+
'',
|
|
137
|
+
`Open in browser: /artifact open ${name}`,
|
|
138
|
+
`List all: /artifact list`,
|
|
139
|
+
]
|
|
140
|
+
.filter(Boolean)
|
|
141
|
+
.join('\n'),
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
const ALLOWED_APPS = new Set([
|
|
4
|
+
'Finder',
|
|
5
|
+
'Safari',
|
|
6
|
+
'Google Chrome',
|
|
7
|
+
'Firefox',
|
|
8
|
+
'Terminal',
|
|
9
|
+
'VS Code',
|
|
10
|
+
'System Settings',
|
|
11
|
+
'Activity Monitor',
|
|
12
|
+
'TextEdit',
|
|
13
|
+
'Preview',
|
|
14
|
+
])
|
|
15
|
+
|
|
16
|
+
export function launchApp(appName: string): { success: boolean; message: string } {
|
|
17
|
+
if (!ALLOWED_APPS.has(appName)) {
|
|
18
|
+
return {
|
|
19
|
+
success: false,
|
|
20
|
+
message: `App "${appName}" is not in the allowed list. Allowed: ${[...ALLOWED_APPS].join(', ')}`,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const platform = process.platform
|
|
26
|
+
if (platform === 'darwin') {
|
|
27
|
+
execSync(`open -a "${appName}"`, { timeout: 5000 })
|
|
28
|
+
} else if (platform === 'linux') {
|
|
29
|
+
execSync(`xdg-open "${appName}"`, { timeout: 5000 })
|
|
30
|
+
} else if (platform === 'win32') {
|
|
31
|
+
execSync(`start "" "${appName}"`, { timeout: 5000, shell: 'cmd.exe' })
|
|
32
|
+
} else {
|
|
33
|
+
return { success: false, message: `Unsupported platform: ${platform}` }
|
|
34
|
+
}
|
|
35
|
+
return { success: true, message: `Launched: ${appName}` }
|
|
36
|
+
} catch (err) {
|
|
37
|
+
return { success: false, message: `Failed to launch ${appName}: ${String(err)}` }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
let browserPromise: Promise<unknown> | null = null
|
|
2
|
+
|
|
3
|
+
async function getPage(): Promise<unknown> {
|
|
4
|
+
if (!browserPromise) {
|
|
5
|
+
// Dynamic import — Playwright is optional
|
|
6
|
+
let playwrightModule: { chromium: unknown }
|
|
7
|
+
try {
|
|
8
|
+
playwrightModule = await import('playwright')
|
|
9
|
+
} catch {
|
|
10
|
+
throw new Error('Playwright is not installed. Install it with: npm install playwright')
|
|
11
|
+
}
|
|
12
|
+
const { chromium } = playwrightModule as {
|
|
13
|
+
chromium: {
|
|
14
|
+
launch: (opts?: Record<string, unknown>) => Promise<{
|
|
15
|
+
newPage: () => Promise<unknown>
|
|
16
|
+
}>
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const browser = await chromium.launch({ headless: false })
|
|
20
|
+
browserPromise = browser.newPage()
|
|
21
|
+
}
|
|
22
|
+
return browserPromise
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function browserNavigate(url: string): Promise<string> {
|
|
26
|
+
const page = (await getPage()) as {
|
|
27
|
+
goto: (u: string) => Promise<void>
|
|
28
|
+
url: () => string
|
|
29
|
+
}
|
|
30
|
+
await page.goto(url)
|
|
31
|
+
return `Navigated to: ${page.url()}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function browserSnapshot(): Promise<string> {
|
|
35
|
+
const page = (await getPage()) as {
|
|
36
|
+
accessibility: { snapshot: () => Promise<unknown> }
|
|
37
|
+
}
|
|
38
|
+
const snapshot = await page.accessibility.snapshot()
|
|
39
|
+
return JSON.stringify(snapshot, null, 2)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function browserClick(uid: string): Promise<string> {
|
|
43
|
+
const page = (await getPage()) as {
|
|
44
|
+
locator: (s: string) => { click: () => Promise<void> }
|
|
45
|
+
}
|
|
46
|
+
await page.locator(`[uid="${uid}"]`).click()
|
|
47
|
+
return `Clicked: ${uid}`
|
|
48
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ToolDefinition } from '../../shared/index.ts'
|
|
2
|
+
import { takeScreenshot } from './screenshot'
|
|
3
|
+
import { launchApp } from './app-launcher'
|
|
4
|
+
import { browserNavigate, browserSnapshot, browserClick } from './browser'
|
|
5
|
+
|
|
6
|
+
export const computerUseTool: ToolDefinition = {
|
|
7
|
+
name: 'ComputerUse',
|
|
8
|
+
description:
|
|
9
|
+
'Take screenshots, launch apps, or control a browser. Always requires user approval.',
|
|
10
|
+
category: 'system',
|
|
11
|
+
permission: 'ask', // ALWAYS ask — security requirement
|
|
12
|
+
parameters: {
|
|
13
|
+
type: 'object',
|
|
14
|
+
properties: {
|
|
15
|
+
action: {
|
|
16
|
+
type: 'string',
|
|
17
|
+
enum: ['screenshot', 'launch', 'browser_navigate', 'browser_snapshot', 'browser_click'],
|
|
18
|
+
description: 'The computer-use action to perform',
|
|
19
|
+
},
|
|
20
|
+
target: {
|
|
21
|
+
type: 'string',
|
|
22
|
+
description: 'App name (launch), URL (browser_navigate), or element UID (browser_click)',
|
|
23
|
+
},
|
|
24
|
+
text: {
|
|
25
|
+
type: 'string',
|
|
26
|
+
description: 'Text to type (reserved for future browser_type action)',
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
required: ['action'],
|
|
30
|
+
},
|
|
31
|
+
async execute(params, _ctx) {
|
|
32
|
+
const action = params.action as string
|
|
33
|
+
const target = (params.target as string) || ''
|
|
34
|
+
|
|
35
|
+
switch (action) {
|
|
36
|
+
case 'screenshot': {
|
|
37
|
+
const result = await takeScreenshot()
|
|
38
|
+
return result.success
|
|
39
|
+
? { success: true, content: `Screenshot captured: ${result.data!.slice(0, 100)}...` }
|
|
40
|
+
: { success: false, content: '', error: result.error }
|
|
41
|
+
}
|
|
42
|
+
case 'launch': {
|
|
43
|
+
const result = launchApp(target)
|
|
44
|
+
return result.success
|
|
45
|
+
? { success: true, content: result.message }
|
|
46
|
+
: { success: false, content: '', error: result.message }
|
|
47
|
+
}
|
|
48
|
+
case 'browser_navigate': {
|
|
49
|
+
try {
|
|
50
|
+
const url = await browserNavigate(target)
|
|
51
|
+
return { success: true, content: url }
|
|
52
|
+
} catch (err) {
|
|
53
|
+
return {
|
|
54
|
+
success: false,
|
|
55
|
+
content: '',
|
|
56
|
+
error: `Browser navigation failed: ${String(err)}`,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
case 'browser_snapshot': {
|
|
61
|
+
try {
|
|
62
|
+
const snapshot = await browserSnapshot()
|
|
63
|
+
return { success: true, content: snapshot }
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return {
|
|
66
|
+
success: false,
|
|
67
|
+
content: '',
|
|
68
|
+
error: `Browser snapshot failed: ${String(err)}`,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
case 'browser_click': {
|
|
73
|
+
try {
|
|
74
|
+
const result = await browserClick(target)
|
|
75
|
+
return { success: true, content: result }
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return {
|
|
78
|
+
success: false,
|
|
79
|
+
content: '',
|
|
80
|
+
error: `Browser click failed: ${String(err)}`,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
default:
|
|
85
|
+
return { success: false, content: '', error: `Unknown action: ${action}` }
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
}
|